devscontext 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.
- devscontext/__init__.py +3 -0
- devscontext/adapters/__init__.py +23 -0
- devscontext/adapters/base.py +105 -0
- devscontext/adapters/fireflies.py +585 -0
- devscontext/adapters/gmail.py +580 -0
- devscontext/adapters/jira.py +639 -0
- devscontext/adapters/local_docs.py +984 -0
- devscontext/adapters/slack.py +804 -0
- devscontext/agents/__init__.py +28 -0
- devscontext/agents/preprocessor.py +775 -0
- devscontext/agents/watcher.py +265 -0
- devscontext/cache.py +151 -0
- devscontext/cli.py +727 -0
- devscontext/config.py +264 -0
- devscontext/constants.py +107 -0
- devscontext/core.py +582 -0
- devscontext/exceptions.py +148 -0
- devscontext/logging.py +181 -0
- devscontext/models.py +504 -0
- devscontext/plugins/__init__.py +49 -0
- devscontext/plugins/base.py +321 -0
- devscontext/plugins/registry.py +544 -0
- devscontext/py.typed +0 -0
- devscontext/rag/__init__.py +113 -0
- devscontext/rag/embeddings.py +296 -0
- devscontext/rag/index.py +323 -0
- devscontext/server.py +374 -0
- devscontext/storage.py +321 -0
- devscontext/synthesis.py +1057 -0
- devscontext/utils.py +297 -0
- devscontext-0.1.0.dist-info/METADATA +253 -0
- devscontext-0.1.0.dist-info/RECORD +35 -0
- devscontext-0.1.0.dist-info/WHEEL +4 -0
- devscontext-0.1.0.dist-info/entry_points.txt +2 -0
- devscontext-0.1.0.dist-info/licenses/LICENSE +21 -0
devscontext/cli.py
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""CLI entry point for DevsContext.
|
|
2
|
+
|
|
3
|
+
This module provides the command-line interface for DevsContext,
|
|
4
|
+
including commands for initialization, testing, and running the server.
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
init: Create configuration file interactively
|
|
8
|
+
test: Test connection to configured adapters
|
|
9
|
+
serve: Start the MCP server (default)
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
devscontext init
|
|
13
|
+
devscontext test --task PROJ-123
|
|
14
|
+
devscontext serve
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
from devscontext import __version__
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _success(msg: str) -> str:
|
|
30
|
+
"""Format success message with green checkmark."""
|
|
31
|
+
return click.style("✓", fg="green") + " " + msg
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _error(msg: str) -> str:
|
|
35
|
+
"""Format error message with red X."""
|
|
36
|
+
return click.style("✗", fg="red") + " " + msg
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _info(msg: str) -> str:
|
|
40
|
+
"""Format info message with blue arrow."""
|
|
41
|
+
return click.style("→", fg="blue") + " " + msg
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@click.group(invoke_without_command=True)
|
|
45
|
+
@click.version_option(version=__version__, prog_name="devscontext")
|
|
46
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
|
|
47
|
+
@click.pass_context
|
|
48
|
+
def cli(ctx: click.Context, verbose: bool) -> None:
|
|
49
|
+
"""DevsContext - MCP server for AI coding context.
|
|
50
|
+
|
|
51
|
+
Provides synthesized engineering context from Jira, meeting transcripts,
|
|
52
|
+
and local documentation to AI coding assistants.
|
|
53
|
+
"""
|
|
54
|
+
ctx.ensure_object(dict)
|
|
55
|
+
ctx.obj["verbose"] = verbose
|
|
56
|
+
|
|
57
|
+
if ctx.invoked_subcommand is None:
|
|
58
|
+
ctx.invoke(serve)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@cli.command()
|
|
62
|
+
def init() -> None:
|
|
63
|
+
"""Create .devscontext.yaml configuration interactively."""
|
|
64
|
+
config_path = Path(".devscontext.yaml")
|
|
65
|
+
gitignore_path = Path(".gitignore")
|
|
66
|
+
|
|
67
|
+
if config_path.exists():
|
|
68
|
+
click.echo(f"Config file already exists: {config_path}")
|
|
69
|
+
if not click.confirm("Overwrite?", default=False):
|
|
70
|
+
click.echo("Aborted.")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
click.echo()
|
|
74
|
+
click.echo(click.style("DevsContext Setup", bold=True))
|
|
75
|
+
click.echo()
|
|
76
|
+
|
|
77
|
+
# Jira configuration
|
|
78
|
+
jira_enabled = click.confirm("Configure Jira?", default=True)
|
|
79
|
+
jira_url = ""
|
|
80
|
+
if jira_enabled:
|
|
81
|
+
jira_url = click.prompt(
|
|
82
|
+
" Jira URL",
|
|
83
|
+
default="https://your-company.atlassian.net",
|
|
84
|
+
)
|
|
85
|
+
click.echo(" " + _info("Set JIRA_EMAIL and JIRA_API_TOKEN environment variables"))
|
|
86
|
+
|
|
87
|
+
# Fireflies configuration
|
|
88
|
+
click.echo()
|
|
89
|
+
fireflies_enabled = click.confirm("Configure Fireflies (meeting transcripts)?", default=False)
|
|
90
|
+
if fireflies_enabled:
|
|
91
|
+
click.echo(" " + _info("Set FIREFLIES_API_KEY environment variable"))
|
|
92
|
+
|
|
93
|
+
# Local docs configuration
|
|
94
|
+
click.echo()
|
|
95
|
+
docs_enabled = click.confirm("Configure local docs?", default=True)
|
|
96
|
+
docs_paths: list[str] = []
|
|
97
|
+
if docs_enabled:
|
|
98
|
+
default_paths = "./docs"
|
|
99
|
+
if Path("CLAUDE.md").exists():
|
|
100
|
+
default_paths = "./docs, ./CLAUDE.md"
|
|
101
|
+
paths_input = click.prompt(" Doc paths (comma-separated)", default=default_paths)
|
|
102
|
+
docs_paths = [p.strip() for p in paths_input.split(",") if p.strip()]
|
|
103
|
+
|
|
104
|
+
# Build config
|
|
105
|
+
config_lines = [
|
|
106
|
+
"# DevsContext Configuration",
|
|
107
|
+
"",
|
|
108
|
+
"adapters:",
|
|
109
|
+
" jira:",
|
|
110
|
+
f" enabled: {str(jira_enabled).lower()}",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
if jira_enabled:
|
|
114
|
+
config_lines.extend(
|
|
115
|
+
[
|
|
116
|
+
f' base_url: "{jira_url}"',
|
|
117
|
+
' email: "${JIRA_EMAIL}"',
|
|
118
|
+
' api_token: "${JIRA_API_TOKEN}"',
|
|
119
|
+
]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
config_lines.extend(
|
|
123
|
+
[
|
|
124
|
+
"",
|
|
125
|
+
" fireflies:",
|
|
126
|
+
f" enabled: {str(fireflies_enabled).lower()}",
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if fireflies_enabled:
|
|
131
|
+
config_lines.append(' api_key: "${FIREFLIES_API_KEY}"')
|
|
132
|
+
|
|
133
|
+
config_lines.extend(
|
|
134
|
+
[
|
|
135
|
+
"",
|
|
136
|
+
" local_docs:",
|
|
137
|
+
f" enabled: {str(docs_enabled).lower()}",
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if docs_enabled and docs_paths:
|
|
142
|
+
config_lines.append(" paths:")
|
|
143
|
+
for path in docs_paths:
|
|
144
|
+
config_lines.append(f' - "{path}"')
|
|
145
|
+
|
|
146
|
+
config_lines.extend(
|
|
147
|
+
[
|
|
148
|
+
"",
|
|
149
|
+
"synthesis:",
|
|
150
|
+
' provider: "anthropic"',
|
|
151
|
+
' model: "claude-3-haiku-20240307"',
|
|
152
|
+
"",
|
|
153
|
+
"cache:",
|
|
154
|
+
" ttl_seconds: 300",
|
|
155
|
+
" max_size: 100",
|
|
156
|
+
"",
|
|
157
|
+
]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
config_content = "\n".join(config_lines)
|
|
161
|
+
config_path.write_text(config_content)
|
|
162
|
+
|
|
163
|
+
# Add to .gitignore if not already there
|
|
164
|
+
gitignore_updated = False
|
|
165
|
+
if gitignore_path.exists():
|
|
166
|
+
gitignore_content = gitignore_path.read_text()
|
|
167
|
+
if ".devscontext.yaml" not in gitignore_content:
|
|
168
|
+
with gitignore_path.open("a") as f:
|
|
169
|
+
if not gitignore_content.endswith("\n"):
|
|
170
|
+
f.write("\n")
|
|
171
|
+
f.write("\n# DevsContext config (contains env var references)\n")
|
|
172
|
+
f.write(".devscontext.yaml\n")
|
|
173
|
+
gitignore_updated = True
|
|
174
|
+
else:
|
|
175
|
+
gitignore_path.write_text("# DevsContext config\n.devscontext.yaml\n")
|
|
176
|
+
gitignore_updated = True
|
|
177
|
+
|
|
178
|
+
# Success message
|
|
179
|
+
click.echo()
|
|
180
|
+
click.echo(_success(f"Created {config_path}"))
|
|
181
|
+
if gitignore_updated:
|
|
182
|
+
click.echo(_success("Added .devscontext.yaml to .gitignore"))
|
|
183
|
+
|
|
184
|
+
click.echo()
|
|
185
|
+
click.echo(click.style("Next steps:", bold=True))
|
|
186
|
+
if jira_enabled:
|
|
187
|
+
click.echo(" export JIRA_EMAIL='your-email@company.com'")
|
|
188
|
+
click.echo(" export JIRA_API_TOKEN='your-api-token'")
|
|
189
|
+
if fireflies_enabled:
|
|
190
|
+
click.echo(" export FIREFLIES_API_KEY='your-api-key'")
|
|
191
|
+
click.echo(" export ANTHROPIC_API_KEY='your-api-key'")
|
|
192
|
+
click.echo()
|
|
193
|
+
click.echo(" devscontext test --task YOUR-123")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@cli.command()
|
|
197
|
+
@click.option("--task", "-t", default=None, help="Jira ticket ID (e.g., PROJ-123)")
|
|
198
|
+
@click.pass_context
|
|
199
|
+
def test(ctx: click.Context, task: str | None) -> None:
|
|
200
|
+
"""Test connection to configured adapters."""
|
|
201
|
+
import asyncio
|
|
202
|
+
|
|
203
|
+
from devscontext.config import load_devscontext_config
|
|
204
|
+
from devscontext.core import DevsContextCore
|
|
205
|
+
|
|
206
|
+
verbose = ctx.obj.get("verbose", False)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
config = load_devscontext_config()
|
|
210
|
+
except FileNotFoundError:
|
|
211
|
+
click.echo(_error("No .devscontext.yaml found. Run 'devscontext init' first."))
|
|
212
|
+
sys.exit(1)
|
|
213
|
+
|
|
214
|
+
click.echo()
|
|
215
|
+
click.echo(click.style("Connection Status", bold=True))
|
|
216
|
+
click.echo()
|
|
217
|
+
|
|
218
|
+
core = DevsContextCore(config)
|
|
219
|
+
|
|
220
|
+
async def run_health_checks() -> dict[str, bool]:
|
|
221
|
+
return await core.health_check()
|
|
222
|
+
|
|
223
|
+
results = asyncio.run(run_health_checks())
|
|
224
|
+
healthy_count = sum(1 for h in results.values() if h)
|
|
225
|
+
|
|
226
|
+
for adapter, healthy in results.items():
|
|
227
|
+
if healthy:
|
|
228
|
+
click.echo(" " + _success(adapter))
|
|
229
|
+
else:
|
|
230
|
+
click.echo(" " + _error(f"{adapter} (check credentials)"))
|
|
231
|
+
|
|
232
|
+
click.echo()
|
|
233
|
+
|
|
234
|
+
if not task:
|
|
235
|
+
click.echo(_info("Use --task PROJ-123 to test fetching context"))
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
if healthy_count == 0:
|
|
239
|
+
click.echo(_error("No healthy adapters. Fix connections before testing."))
|
|
240
|
+
sys.exit(1)
|
|
241
|
+
|
|
242
|
+
click.echo(click.style(f"Fetching context for {task}...", bold=True))
|
|
243
|
+
click.echo()
|
|
244
|
+
|
|
245
|
+
start_time = time.monotonic()
|
|
246
|
+
|
|
247
|
+
async def fetch_context() -> tuple[str, list[str]]:
|
|
248
|
+
result = await core.get_task_context(task)
|
|
249
|
+
return result.synthesized, result.sources_used
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
output, sources = asyncio.run(fetch_context())
|
|
253
|
+
duration = time.monotonic() - start_time
|
|
254
|
+
|
|
255
|
+
click.echo(output)
|
|
256
|
+
click.echo()
|
|
257
|
+
click.echo(
|
|
258
|
+
click.style(
|
|
259
|
+
f"Fetched from {len(sources)} source(s) and synthesized in {duration:.1f}s",
|
|
260
|
+
fg="cyan",
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
if verbose:
|
|
265
|
+
import traceback
|
|
266
|
+
|
|
267
|
+
click.echo(traceback.format_exc(), err=True)
|
|
268
|
+
click.echo(_error(f"Failed: {e}"), err=True)
|
|
269
|
+
sys.exit(1)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@cli.command()
|
|
273
|
+
@click.pass_context
|
|
274
|
+
def serve(ctx: click.Context) -> None:
|
|
275
|
+
"""Start the MCP server (stdio transport)."""
|
|
276
|
+
|
|
277
|
+
# Print startup message to stderr (stdout is for MCP protocol)
|
|
278
|
+
click.echo(
|
|
279
|
+
click.style("DevsContext", bold=True) + " MCP server running",
|
|
280
|
+
err=True,
|
|
281
|
+
)
|
|
282
|
+
click.echo(
|
|
283
|
+
"Tools: get_task_context, search_context, get_standards",
|
|
284
|
+
err=True,
|
|
285
|
+
)
|
|
286
|
+
click.echo(err=True)
|
|
287
|
+
|
|
288
|
+
from devscontext.server import main as server_main
|
|
289
|
+
|
|
290
|
+
server_main()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# =============================================================================
|
|
294
|
+
# RAG INDEXING COMMAND
|
|
295
|
+
# =============================================================================
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@cli.command("index-docs")
|
|
299
|
+
@click.option("--rebuild", is_flag=True, help="Clear and rebuild the entire index")
|
|
300
|
+
@click.option("--status", "show_status", is_flag=True, help="Show index statistics only")
|
|
301
|
+
@click.pass_context
|
|
302
|
+
def index_docs(ctx: click.Context, rebuild: bool, show_status: bool) -> None:
|
|
303
|
+
"""Build embedding index for local documentation.
|
|
304
|
+
|
|
305
|
+
Creates vector embeddings for all markdown documents in the configured
|
|
306
|
+
doc paths, enabling semantic search for better doc matching.
|
|
307
|
+
|
|
308
|
+
This command requires RAG to be configured in .devscontext.yaml:
|
|
309
|
+
|
|
310
|
+
\b
|
|
311
|
+
sources:
|
|
312
|
+
docs:
|
|
313
|
+
rag:
|
|
314
|
+
enabled: true
|
|
315
|
+
embedding_provider: local
|
|
316
|
+
embedding_model: all-MiniLM-L6-v2
|
|
317
|
+
|
|
318
|
+
Requires: pip install devscontext[rag]
|
|
319
|
+
"""
|
|
320
|
+
import asyncio
|
|
321
|
+
|
|
322
|
+
from devscontext.adapters.local_docs import LocalDocsAdapter
|
|
323
|
+
from devscontext.config import load_devscontext_config
|
|
324
|
+
|
|
325
|
+
verbose = ctx.obj.get("verbose", False)
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
config = load_devscontext_config()
|
|
329
|
+
except FileNotFoundError:
|
|
330
|
+
click.echo(_error("No .devscontext.yaml found. Run 'devscontext init' first."))
|
|
331
|
+
sys.exit(1)
|
|
332
|
+
|
|
333
|
+
if not config.sources.docs.rag:
|
|
334
|
+
click.echo(_error("RAG not configured in .devscontext.yaml"))
|
|
335
|
+
click.echo()
|
|
336
|
+
click.echo("Add the following to your config:")
|
|
337
|
+
click.echo()
|
|
338
|
+
click.echo(" sources:")
|
|
339
|
+
click.echo(" docs:")
|
|
340
|
+
click.echo(" rag:")
|
|
341
|
+
click.echo(" enabled: true")
|
|
342
|
+
click.echo(" embedding_provider: local")
|
|
343
|
+
sys.exit(1)
|
|
344
|
+
|
|
345
|
+
# Check if RAG dependencies are available
|
|
346
|
+
try:
|
|
347
|
+
from devscontext.rag import is_rag_available
|
|
348
|
+
|
|
349
|
+
if not is_rag_available():
|
|
350
|
+
click.echo(_error("RAG dependencies not installed"))
|
|
351
|
+
click.echo()
|
|
352
|
+
click.echo("Install with: pip install devscontext[rag]")
|
|
353
|
+
sys.exit(1)
|
|
354
|
+
except ImportError:
|
|
355
|
+
click.echo(_error("RAG module not available"))
|
|
356
|
+
sys.exit(1)
|
|
357
|
+
|
|
358
|
+
# Status only mode
|
|
359
|
+
if show_status:
|
|
360
|
+
from devscontext.rag import DocumentIndex
|
|
361
|
+
|
|
362
|
+
index = DocumentIndex(config.sources.docs.rag.index_path)
|
|
363
|
+
|
|
364
|
+
click.echo()
|
|
365
|
+
click.echo(click.style("RAG Index Status", bold=True))
|
|
366
|
+
click.echo()
|
|
367
|
+
|
|
368
|
+
if not index.exists():
|
|
369
|
+
click.echo(_error("Index does not exist"))
|
|
370
|
+
click.echo(_info("Run 'devscontext index-docs' to build it"))
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
index.load()
|
|
374
|
+
stats = index.get_stats()
|
|
375
|
+
|
|
376
|
+
click.echo(f" Index path: {stats['index_path']}")
|
|
377
|
+
click.echo(f" Model: {stats['model']}")
|
|
378
|
+
click.echo(f" Dimension: {stats['dimension']}")
|
|
379
|
+
click.echo(f" Sections: {stats['section_count']}")
|
|
380
|
+
if stats["indexed_at"]:
|
|
381
|
+
click.echo(f" Indexed at: {stats['indexed_at']}")
|
|
382
|
+
|
|
383
|
+
if stats.get("doc_types"):
|
|
384
|
+
click.echo()
|
|
385
|
+
click.echo(" Document types:")
|
|
386
|
+
for doc_type, count in stats["doc_types"].items():
|
|
387
|
+
click.echo(f" {doc_type}: {count}")
|
|
388
|
+
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
# Build/rebuild index
|
|
392
|
+
click.echo()
|
|
393
|
+
click.echo(click.style("Building RAG Index", bold=True))
|
|
394
|
+
click.echo()
|
|
395
|
+
|
|
396
|
+
if rebuild:
|
|
397
|
+
click.echo(_info("Rebuild mode - clearing existing index"))
|
|
398
|
+
|
|
399
|
+
click.echo(_info(f"Model: {config.sources.docs.rag.embedding_model}"))
|
|
400
|
+
click.echo(_info(f"Doc paths: {', '.join(config.sources.docs.paths)}"))
|
|
401
|
+
click.echo()
|
|
402
|
+
|
|
403
|
+
adapter = LocalDocsAdapter(config.sources.docs)
|
|
404
|
+
|
|
405
|
+
async def build_index() -> dict[str, Any]:
|
|
406
|
+
return await adapter.index_documents(rebuild=rebuild)
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
start_time = time.monotonic()
|
|
410
|
+
result = asyncio.run(build_index())
|
|
411
|
+
duration = time.monotonic() - start_time
|
|
412
|
+
|
|
413
|
+
if result["status"] == "no_docs":
|
|
414
|
+
click.echo(_error("No documents found in configured paths"))
|
|
415
|
+
sys.exit(1)
|
|
416
|
+
|
|
417
|
+
click.echo(_success(f"Indexed {result['sections_indexed']} sections in {duration:.1f}s"))
|
|
418
|
+
click.echo()
|
|
419
|
+
click.echo(f" Files scanned: {result['files_scanned']}")
|
|
420
|
+
click.echo(f" Dimension: {result['dimension']}")
|
|
421
|
+
click.echo(f" Index path: {result['index_path']}")
|
|
422
|
+
|
|
423
|
+
except ImportError as e:
|
|
424
|
+
click.echo(_error(f"Missing dependency: {e}"))
|
|
425
|
+
click.echo()
|
|
426
|
+
click.echo("Install with: pip install devscontext[rag]")
|
|
427
|
+
sys.exit(1)
|
|
428
|
+
except Exception as e:
|
|
429
|
+
if verbose:
|
|
430
|
+
import traceback
|
|
431
|
+
|
|
432
|
+
click.echo(traceback.format_exc(), err=True)
|
|
433
|
+
click.echo(_error(f"Indexing failed: {e}"), err=True)
|
|
434
|
+
sys.exit(1)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# =============================================================================
|
|
438
|
+
# AGENT COMMANDS
|
|
439
|
+
# =============================================================================
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@cli.group()
|
|
443
|
+
@click.pass_context
|
|
444
|
+
def agent(ctx: click.Context) -> None:
|
|
445
|
+
"""Manage the pre-processing agent.
|
|
446
|
+
|
|
447
|
+
The agent watches Jira for tickets in a target status (e.g., "Ready for
|
|
448
|
+
Development") and pre-builds rich context before anyone picks them up.
|
|
449
|
+
|
|
450
|
+
Commands:
|
|
451
|
+
start: Run polling agent in foreground
|
|
452
|
+
run-once: Single poll cycle, then exit
|
|
453
|
+
status: Show pre-built context stats
|
|
454
|
+
process: Manually process a specific ticket
|
|
455
|
+
"""
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@agent.command()
|
|
460
|
+
@click.pass_context
|
|
461
|
+
def start(ctx: click.Context) -> None:
|
|
462
|
+
"""Start the polling agent in foreground.
|
|
463
|
+
|
|
464
|
+
Polls Jira periodically for tickets in the target status and processes
|
|
465
|
+
them through the preprocessing pipeline. Press Ctrl+C to stop.
|
|
466
|
+
"""
|
|
467
|
+
import asyncio
|
|
468
|
+
import signal
|
|
469
|
+
|
|
470
|
+
from devscontext.agents import JiraWatcher, PreprocessingPipeline
|
|
471
|
+
from devscontext.config import load_devscontext_config
|
|
472
|
+
from devscontext.storage import PrebuiltContextStorage
|
|
473
|
+
|
|
474
|
+
verbose = ctx.obj.get("verbose", False)
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
config = load_devscontext_config()
|
|
478
|
+
except FileNotFoundError:
|
|
479
|
+
click.echo(_error("No .devscontext.yaml found. Run 'devscontext init' first."))
|
|
480
|
+
sys.exit(1)
|
|
481
|
+
|
|
482
|
+
if not config.agents.preprocessor.enabled:
|
|
483
|
+
click.echo(_error("Preprocessor agent not enabled in config."))
|
|
484
|
+
click.echo(_info("Set agents.preprocessor.enabled: true in .devscontext.yaml"))
|
|
485
|
+
sys.exit(1)
|
|
486
|
+
|
|
487
|
+
if not config.sources.jira.enabled:
|
|
488
|
+
click.echo(_error("Jira adapter not enabled. Agent requires Jira."))
|
|
489
|
+
sys.exit(1)
|
|
490
|
+
|
|
491
|
+
click.echo()
|
|
492
|
+
click.echo(click.style("DevsContext Agent", bold=True))
|
|
493
|
+
click.echo()
|
|
494
|
+
click.echo(
|
|
495
|
+
_info(f"Polling every {config.agents.preprocessor.trigger.poll_interval_minutes} minutes")
|
|
496
|
+
)
|
|
497
|
+
click.echo(_info(f"Watching for status: {config.agents.preprocessor.jira_status}"))
|
|
498
|
+
click.echo(_info(f"Project(s): {config.agents.preprocessor.jira_project}"))
|
|
499
|
+
click.echo()
|
|
500
|
+
|
|
501
|
+
async def run_agent() -> None:
|
|
502
|
+
storage = PrebuiltContextStorage(config.storage.path)
|
|
503
|
+
await storage.initialize()
|
|
504
|
+
|
|
505
|
+
pipeline = PreprocessingPipeline(config, storage)
|
|
506
|
+
watcher = JiraWatcher(config, pipeline)
|
|
507
|
+
|
|
508
|
+
# Handle Ctrl+C gracefully
|
|
509
|
+
loop = asyncio.get_event_loop()
|
|
510
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
511
|
+
loop.add_signal_handler(sig, watcher.stop)
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
click.echo(_success("Agent started. Press Ctrl+C to stop."))
|
|
515
|
+
click.echo()
|
|
516
|
+
await watcher.run()
|
|
517
|
+
finally:
|
|
518
|
+
await watcher.close()
|
|
519
|
+
await pipeline.close()
|
|
520
|
+
await storage.close()
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
asyncio.run(run_agent())
|
|
524
|
+
except Exception as e:
|
|
525
|
+
if verbose:
|
|
526
|
+
import traceback
|
|
527
|
+
|
|
528
|
+
click.echo(traceback.format_exc(), err=True)
|
|
529
|
+
click.echo(_error(f"Agent error: {e}"), err=True)
|
|
530
|
+
sys.exit(1)
|
|
531
|
+
|
|
532
|
+
click.echo()
|
|
533
|
+
click.echo(_success("Agent stopped."))
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@agent.command("run-once")
|
|
537
|
+
@click.pass_context
|
|
538
|
+
def run_once(ctx: click.Context) -> None:
|
|
539
|
+
"""Single run: check for ready tickets, process, exit.
|
|
540
|
+
|
|
541
|
+
Useful for cron jobs or CI pipelines. Performs one poll cycle,
|
|
542
|
+
processes any new tickets found, and exits.
|
|
543
|
+
"""
|
|
544
|
+
import asyncio
|
|
545
|
+
|
|
546
|
+
from devscontext.agents import JiraWatcher, PreprocessingPipeline
|
|
547
|
+
from devscontext.config import load_devscontext_config
|
|
548
|
+
from devscontext.storage import PrebuiltContextStorage
|
|
549
|
+
|
|
550
|
+
verbose = ctx.obj.get("verbose", False)
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
config = load_devscontext_config()
|
|
554
|
+
except FileNotFoundError:
|
|
555
|
+
click.echo(_error("No .devscontext.yaml found."))
|
|
556
|
+
sys.exit(1)
|
|
557
|
+
|
|
558
|
+
if not config.agents.preprocessor.enabled:
|
|
559
|
+
click.echo(_error("Preprocessor agent not enabled in config."))
|
|
560
|
+
sys.exit(1)
|
|
561
|
+
|
|
562
|
+
click.echo(click.style("DevsContext Agent - Single Run", bold=True))
|
|
563
|
+
click.echo()
|
|
564
|
+
|
|
565
|
+
async def run_single() -> int:
|
|
566
|
+
storage = PrebuiltContextStorage(config.storage.path)
|
|
567
|
+
await storage.initialize()
|
|
568
|
+
|
|
569
|
+
pipeline = PreprocessingPipeline(config, storage)
|
|
570
|
+
watcher = JiraWatcher(config, pipeline)
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
processed = await watcher.run_once()
|
|
574
|
+
return processed
|
|
575
|
+
finally:
|
|
576
|
+
await watcher.close()
|
|
577
|
+
await pipeline.close()
|
|
578
|
+
await storage.close()
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
processed = asyncio.run(run_single())
|
|
582
|
+
click.echo()
|
|
583
|
+
click.echo(_success(f"Processed {processed} ticket(s)."))
|
|
584
|
+
except Exception as e:
|
|
585
|
+
if verbose:
|
|
586
|
+
import traceback
|
|
587
|
+
|
|
588
|
+
click.echo(traceback.format_exc(), err=True)
|
|
589
|
+
click.echo(_error(f"Error: {e}"), err=True)
|
|
590
|
+
sys.exit(1)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@agent.command()
|
|
594
|
+
@click.pass_context
|
|
595
|
+
def status(ctx: click.Context) -> None:
|
|
596
|
+
"""Show pre-built context stats.
|
|
597
|
+
|
|
598
|
+
Displays statistics about stored pre-built context including
|
|
599
|
+
total count, active count, average quality, and last build time.
|
|
600
|
+
"""
|
|
601
|
+
import asyncio
|
|
602
|
+
|
|
603
|
+
from devscontext.config import load_devscontext_config
|
|
604
|
+
from devscontext.storage import PrebuiltContextStorage
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
config = load_devscontext_config()
|
|
608
|
+
except FileNotFoundError:
|
|
609
|
+
click.echo(_error("No .devscontext.yaml found."))
|
|
610
|
+
sys.exit(1)
|
|
611
|
+
|
|
612
|
+
async def get_status() -> dict[str, Any]:
|
|
613
|
+
storage = PrebuiltContextStorage(config.storage.path)
|
|
614
|
+
try:
|
|
615
|
+
await storage.initialize()
|
|
616
|
+
stats = await storage.get_stats()
|
|
617
|
+
return stats
|
|
618
|
+
finally:
|
|
619
|
+
await storage.close()
|
|
620
|
+
|
|
621
|
+
try:
|
|
622
|
+
stats = asyncio.run(get_status())
|
|
623
|
+
except Exception as e:
|
|
624
|
+
click.echo(_error(f"Could not read storage: {e}"))
|
|
625
|
+
sys.exit(1)
|
|
626
|
+
|
|
627
|
+
click.echo()
|
|
628
|
+
click.echo(click.style("Pre-built Context Storage", bold=True))
|
|
629
|
+
click.echo()
|
|
630
|
+
click.echo(f" Total contexts: {stats['total']}")
|
|
631
|
+
click.echo(f" Active (not expired): {stats['active']}")
|
|
632
|
+
click.echo(f" Expired: {stats['expired']}")
|
|
633
|
+
|
|
634
|
+
if stats["avg_quality"] > 0:
|
|
635
|
+
click.echo(f" Average quality: {stats['avg_quality']:.1%}")
|
|
636
|
+
|
|
637
|
+
if stats["last_build"]:
|
|
638
|
+
click.echo(f" Last build: {stats['last_build']}")
|
|
639
|
+
else:
|
|
640
|
+
click.echo(" Last build: (none)")
|
|
641
|
+
|
|
642
|
+
click.echo()
|
|
643
|
+
click.echo(_info(f"Storage path: {config.storage.path}"))
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@agent.command()
|
|
647
|
+
@click.argument("task_id")
|
|
648
|
+
@click.pass_context
|
|
649
|
+
def process(ctx: click.Context, task_id: str) -> None:
|
|
650
|
+
"""Manually trigger pre-processing for a specific ticket.
|
|
651
|
+
|
|
652
|
+
TASK_ID is the Jira ticket ID (e.g., PROJ-123).
|
|
653
|
+
|
|
654
|
+
This bypasses the watcher and immediately processes the specified
|
|
655
|
+
ticket through the full preprocessing pipeline.
|
|
656
|
+
"""
|
|
657
|
+
import asyncio
|
|
658
|
+
|
|
659
|
+
from devscontext.agents import PreprocessingPipeline
|
|
660
|
+
from devscontext.config import load_devscontext_config
|
|
661
|
+
from devscontext.storage import PrebuiltContextStorage
|
|
662
|
+
|
|
663
|
+
verbose = ctx.obj.get("verbose", False)
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
config = load_devscontext_config()
|
|
667
|
+
except FileNotFoundError:
|
|
668
|
+
click.echo(_error("No .devscontext.yaml found."))
|
|
669
|
+
sys.exit(1)
|
|
670
|
+
|
|
671
|
+
click.echo()
|
|
672
|
+
click.echo(click.style(f"Processing {task_id}", bold=True))
|
|
673
|
+
click.echo()
|
|
674
|
+
|
|
675
|
+
start_time = time.monotonic()
|
|
676
|
+
|
|
677
|
+
async def process_ticket() -> dict[str, Any]:
|
|
678
|
+
storage = PrebuiltContextStorage(config.storage.path)
|
|
679
|
+
await storage.initialize()
|
|
680
|
+
|
|
681
|
+
pipeline = PreprocessingPipeline(config, storage)
|
|
682
|
+
|
|
683
|
+
try:
|
|
684
|
+
context = await pipeline.process(task_id)
|
|
685
|
+
return {
|
|
686
|
+
"quality_score": context.context_quality_score,
|
|
687
|
+
"gaps": context.gaps,
|
|
688
|
+
"sources_count": len(context.sources_used),
|
|
689
|
+
}
|
|
690
|
+
finally:
|
|
691
|
+
await pipeline.close()
|
|
692
|
+
await storage.close()
|
|
693
|
+
|
|
694
|
+
try:
|
|
695
|
+
result = asyncio.run(process_ticket())
|
|
696
|
+
duration = time.monotonic() - start_time
|
|
697
|
+
|
|
698
|
+
click.echo(_success(f"Processed in {duration:.1f}s"))
|
|
699
|
+
click.echo()
|
|
700
|
+
click.echo(f" Quality score: {result['quality_score']:.1%}")
|
|
701
|
+
click.echo(f" Sources used: {result['sources_count']}")
|
|
702
|
+
|
|
703
|
+
if result["gaps"]:
|
|
704
|
+
click.echo()
|
|
705
|
+
click.echo(click.style("Identified gaps:", fg="yellow"))
|
|
706
|
+
for gap in result["gaps"]:
|
|
707
|
+
click.echo(f" - {gap}")
|
|
708
|
+
else:
|
|
709
|
+
click.echo()
|
|
710
|
+
click.echo(_success("No gaps identified - context is complete!"))
|
|
711
|
+
|
|
712
|
+
except Exception as e:
|
|
713
|
+
if verbose:
|
|
714
|
+
import traceback
|
|
715
|
+
|
|
716
|
+
click.echo(traceback.format_exc(), err=True)
|
|
717
|
+
click.echo(_error(f"Failed to process: {e}"), err=True)
|
|
718
|
+
sys.exit(1)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def main() -> None:
|
|
722
|
+
"""Main entry point for the CLI."""
|
|
723
|
+
cli()
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
if __name__ == "__main__":
|
|
727
|
+
main()
|