code-explore 0.1.0__tar.gz → 0.3.0__tar.gz

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 (51) hide show
  1. code_explore-0.3.0/.github/workflows/pages.yml +44 -0
  2. {code_explore-0.1.0 → code_explore-0.3.0}/PKG-INFO +3 -1
  3. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/api/main.py +93 -17
  4. code_explore-0.3.0/code_explore/cli/config_cmd.py +96 -0
  5. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/cli/main.py +123 -15
  6. code_explore-0.3.0/code_explore/config.py +329 -0
  7. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/database.py +7 -4
  8. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/indexer/embeddings.py +36 -18
  9. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/models.py +25 -0
  10. code_explore-0.3.0/code_explore/search/filters.py +113 -0
  11. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/search/hybrid.py +6 -4
  12. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/search/semantic.py +5 -3
  13. code_explore-0.3.0/code_explore/static/app.js +482 -0
  14. code_explore-0.3.0/code_explore/static/index.html +78 -0
  15. code_explore-0.3.0/code_explore/static/style.css +349 -0
  16. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/summarizer/ollama.py +30 -15
  17. code_explore-0.3.0/code_explore/tagger/__init__.py +56 -0
  18. code_explore-0.3.0/docs/CNAME +1 -0
  19. code_explore-0.3.0/docs/index.html +93 -0
  20. code_explore-0.3.0/install.cmd +58 -0
  21. code_explore-0.3.0/install.ps1 +84 -0
  22. code_explore-0.3.0/install.sh +110 -0
  23. {code_explore-0.1.0 → code_explore-0.3.0}/pyproject.toml +9 -1
  24. {code_explore-0.1.0 → code_explore-0.3.0}/tests/conftest.py +9 -0
  25. code_explore-0.3.0/tests/test_config.py +314 -0
  26. code_explore-0.3.0/tests/test_config_cli.py +121 -0
  27. {code_explore-0.1.0 → code_explore-0.3.0}/tests/test_search_hybrid.py +3 -2
  28. {code_explore-0.1.0 → code_explore-0.3.0}/.editorconfig +0 -0
  29. {code_explore-0.1.0 → code_explore-0.3.0}/.github/workflows/publish.yml +0 -0
  30. {code_explore-0.1.0 → code_explore-0.3.0}/.gitignore +0 -0
  31. {code_explore-0.1.0 → code_explore-0.3.0}/README.md +0 -0
  32. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/__init__.py +0 -0
  33. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/analyzer/__init__.py +0 -0
  34. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/analyzer/dependencies.py +0 -0
  35. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/analyzer/language.py +0 -0
  36. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/analyzer/metrics.py +0 -0
  37. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/analyzer/patterns.py +0 -0
  38. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/api/__init__.py +0 -0
  39. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/cli/__init__.py +0 -0
  40. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/indexer/__init__.py +0 -0
  41. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/scanner/__init__.py +0 -0
  42. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/scanner/git_info.py +0 -0
  43. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/scanner/local.py +0 -0
  44. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/scanner/readme.py +0 -0
  45. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/search/__init__.py +0 -0
  46. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/search/fulltext.py +0 -0
  47. {code_explore-0.1.0 → code_explore-0.3.0}/code_explore/summarizer/__init__.py +0 -0
  48. {code_explore-0.1.0 → code_explore-0.3.0}/tests/__init__.py +0 -0
  49. {code_explore-0.1.0 → code_explore-0.3.0}/tests/test_cli.py +0 -0
  50. {code_explore-0.1.0 → code_explore-0.3.0}/tests/test_database.py +0 -0
  51. {code_explore-0.1.0 → code_explore-0.3.0}/tests/test_models.py +0 -0
@@ -0,0 +1,44 @@
1
+ name: Deploy to GitHub Pages
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths:
7
+ - 'docs/**'
8
+ - 'install.*'
9
+
10
+ permissions:
11
+ contents: read
12
+ pages: write
13
+ id-token: write
14
+
15
+ concurrency:
16
+ group: pages
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ build:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - name: Prepare site
26
+ run: |
27
+ mkdir -p _site
28
+ cp docs/index.html _site/index.html
29
+ cp docs/CNAME _site/CNAME
30
+ cp install.sh _site/install.sh
31
+ cp install.ps1 _site/install.ps1
32
+ cp install.cmd _site/install.cmd
33
+
34
+ - uses: actions/upload-pages-artifact@v3
35
+
36
+ deploy:
37
+ needs: build
38
+ runs-on: ubuntu-latest
39
+ environment:
40
+ name: github-pages
41
+ url: ${{ steps.deployment.outputs.page_url }}
42
+ steps:
43
+ - id: deployment
44
+ uses: actions/deploy-pages@v4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-explore
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Developer knowledge base CLI — scan, index, and search your programming projects
5
5
  Project-URL: Homepage, https://github.com/aipioneers/code-explore
6
6
  Project-URL: Repository, https://github.com/aipioneers/code-explore
@@ -24,7 +24,9 @@ Requires-Dist: httpx>=0.24.0
24
24
  Requires-Dist: lancedb>=0.4.0
25
25
  Requires-Dist: pyarrow>=14.0.0
26
26
  Requires-Dist: pydantic>=2.0.0
27
+ Requires-Dist: pyyaml>=6.0.0
27
28
  Requires-Dist: rich>=13.0.0
29
+ Requires-Dist: tomli-w>=1.0.0
28
30
  Requires-Dist: typer>=0.9.0
29
31
  Provides-Extra: api
30
32
  Requires-Dist: fastapi>=0.100.0; extra == 'api'
@@ -1,18 +1,20 @@
1
1
  """FastAPI REST API for Code Explore."""
2
2
 
3
- import asyncio
4
3
  import hashlib
4
+ from collections import Counter
5
5
  from datetime import datetime
6
6
  from pathlib import Path
7
7
 
8
8
  from fastapi import FastAPI, HTTPException, Query
9
9
  from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.staticfiles import StaticFiles
10
11
  from pydantic import BaseModel
11
12
 
12
13
  from code_explore.database import init_db, save_project, get_project, get_all_projects, get_project_count
13
- from code_explore.models import Project, ProjectSource, ProjectStatus
14
+ from code_explore.models import Project, ProjectSource, ProjectStatus, SearchFacets
15
+ from code_explore.search.filters import apply_filters, filter_projects, compute_facets
14
16
 
15
- app = FastAPI(title="Code Explore", version="0.1.0", description="Developer knowledge base API")
17
+ app = FastAPI(title="Code Explore", version="0.3.0", description="Developer knowledge base API")
16
18
 
17
19
  app.add_middleware(
18
20
  CORSMiddleware,
@@ -43,36 +45,38 @@ class StatsResponse(BaseModel):
43
45
  languages: dict[str, int]
44
46
  frameworks: dict[str, int]
45
47
  patterns: dict[str, int]
48
+ ai_tags: dict[str, int]
46
49
  total_files: int
47
50
  total_lines: int
48
51
  statuses: dict[str, int]
49
52
 
50
53
 
54
+ class TagInfo(BaseModel):
55
+ value: str
56
+ category: str
57
+ count: int
58
+
59
+
60
+ class TagsResponse(BaseModel):
61
+ tags: list[TagInfo]
62
+ total_tags: int
63
+ categories: dict[str, int]
64
+
65
+
51
66
  @app.get("/api/projects", response_model=list[Project])
52
67
  async def list_projects(
53
68
  language: str | None = Query(None, description="Filter by primary language"),
54
69
  framework: str | None = Query(None, description="Filter by framework"),
55
70
  source: str | None = Query(None, description="Filter by source (local, github, gitlab)"),
71
+ tag: str | None = Query(None, description="Filter by AI tag"),
56
72
  ):
57
73
  projects = get_all_projects()
58
74
 
59
- if language:
60
- lang_lower = language.lower()
61
- projects = [
62
- p for p in projects
63
- if p.primary_language and p.primary_language.lower() == lang_lower
64
- ]
65
-
66
- if framework:
67
- fw_lower = framework.lower()
68
- projects = [
69
- p for p in projects
70
- if any(f.lower() == fw_lower for f in p.frameworks)
71
- ]
72
-
73
75
  if source:
74
76
  projects = [p for p in projects if p.source.value == source]
75
77
 
78
+ projects = filter_projects(projects, language=language, framework=framework, tag=tag)
79
+
76
80
  return projects
77
81
 
78
82
 
@@ -89,6 +93,10 @@ async def search_projects(
89
93
  q: str = Query(..., description="Search query"),
90
94
  mode: str = Query("hybrid", description="Search mode: fulltext, semantic, or hybrid"),
91
95
  limit: int = Query(20, ge=1, le=100, description="Maximum results"),
96
+ language: str | None = Query(None, description="Filter by primary language"),
97
+ framework: str | None = Query(None, description="Filter by framework"),
98
+ pattern: str | None = Query(None, description="Filter by pattern"),
99
+ tag: str | None = Query(None, description="Filter by AI tag"),
92
100
  ):
93
101
  if mode == "fulltext":
94
102
  from code_explore.search.fulltext import search as fulltext_search
@@ -100,6 +108,9 @@ async def search_projects(
100
108
  from code_explore.search.hybrid import search as hybrid_search
101
109
  results = hybrid_search(q, limit=limit)
102
110
 
111
+ # Apply post-filters
112
+ results = apply_filters(results, language=language, framework=framework, pattern=pattern, tag=tag)
113
+
103
114
  return [
104
115
  {
105
116
  "project": r.project.model_dump(),
@@ -111,6 +122,61 @@ async def search_projects(
111
122
  ]
112
123
 
113
124
 
125
+ @app.get("/api/facets", response_model=SearchFacets)
126
+ async def get_facets(
127
+ q: str | None = Query(None, description="Search query to scope facets"),
128
+ language: str | None = Query(None, description="Active language filter"),
129
+ framework: str | None = Query(None, description="Active framework filter"),
130
+ pattern: str | None = Query(None, description="Active pattern filter"),
131
+ tag: str | None = Query(None, description="Active tag filter"),
132
+ ):
133
+ if q:
134
+ # Get search results first, then compute facets from those
135
+ from code_explore.search.hybrid import search as hybrid_search
136
+ results = hybrid_search(q, limit=500)
137
+ projects = [r.project for r in results]
138
+ else:
139
+ projects = get_all_projects()
140
+
141
+ # Apply active filters to scope facets
142
+ projects = filter_projects(projects, language=language, framework=framework, pattern=pattern, tag=tag)
143
+
144
+ return compute_facets(projects)
145
+
146
+
147
+ @app.get("/api/tags", response_model=TagsResponse)
148
+ async def get_tags(
149
+ category: str | None = Query(None, description="Filter by category: domain, technology-role, maturity"),
150
+ ):
151
+ projects = get_all_projects()
152
+
153
+ tag_counts: Counter[str] = Counter()
154
+ tag_categories: dict[str, str] = {}
155
+
156
+ for p in projects:
157
+ for t in p.ai_tags:
158
+ cat = t.category.value if hasattr(t.category, "value") else str(t.category)
159
+ if category and cat != category:
160
+ continue
161
+ tag_counts[t.value] += 1
162
+ tag_categories[t.value] = cat
163
+
164
+ tags_list = [
165
+ TagInfo(value=value, category=tag_categories[value], count=count)
166
+ for value, count in tag_counts.most_common()
167
+ ]
168
+
169
+ category_counts: Counter[str] = Counter()
170
+ for t in tags_list:
171
+ category_counts[t.category] += 1
172
+
173
+ return TagsResponse(
174
+ tags=tags_list,
175
+ total_tags=len(tags_list),
176
+ categories=dict(category_counts),
177
+ )
178
+
179
+
114
180
  @app.get("/api/stats", response_model=StatsResponse)
115
181
  async def get_stats():
116
182
  projects = get_all_projects()
@@ -118,6 +184,7 @@ async def get_stats():
118
184
  languages: dict[str, int] = {}
119
185
  frameworks: dict[str, int] = {}
120
186
  patterns: dict[str, int] = {}
187
+ ai_tags: dict[str, int] = {}
121
188
  statuses: dict[str, int] = {}
122
189
  total_files = 0
123
190
  total_lines = 0
@@ -129,6 +196,8 @@ async def get_stats():
129
196
  frameworks[fw] = frameworks.get(fw, 0) + 1
130
197
  for pat in p.patterns:
131
198
  patterns[pat.name] = patterns.get(pat.name, 0) + 1
199
+ for t in p.ai_tags:
200
+ ai_tags[t.value] = ai_tags.get(t.value, 0) + 1
132
201
  statuses[p.status.value] = statuses.get(p.status.value, 0) + 1
133
202
  total_files += p.quality.total_files
134
203
  total_lines += p.quality.total_lines
@@ -138,6 +207,7 @@ async def get_stats():
138
207
  languages=dict(sorted(languages.items(), key=lambda x: x[1], reverse=True)),
139
208
  frameworks=dict(sorted(frameworks.items(), key=lambda x: x[1], reverse=True)),
140
209
  patterns=dict(sorted(patterns.items(), key=lambda x: x[1], reverse=True)),
210
+ ai_tags=dict(sorted(ai_tags.items(), key=lambda x: x[1], reverse=True)),
141
211
  total_files=total_files,
142
212
  total_lines=total_lines,
143
213
  statuses=statuses,
@@ -195,3 +265,9 @@ async def trigger_scan(request: ScanRequest):
195
265
  results.append(project)
196
266
 
197
267
  return ScanResponse(scanned=len(results), projects=results)
268
+
269
+
270
+ # Mount static files for the dashboard (must be after API routes)
271
+ _static_dir = Path(__file__).parent.parent / "static"
272
+ if _static_dir.is_dir():
273
+ app.mount("/", StaticFiles(directory=str(_static_dir), html=True), name="static")
@@ -0,0 +1,96 @@
1
+ """CLI commands for configuration management."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from code_explore.config import (
8
+ _get_config_dir,
9
+ get_config_path,
10
+ get_resolved_settings,
11
+ reset_config,
12
+ write_default_config,
13
+ _discover_config_file,
14
+ )
15
+
16
+ config_app = typer.Typer(
17
+ name="config",
18
+ help="Manage code-explore configuration.",
19
+ no_args_is_help=True,
20
+ )
21
+ console = Console()
22
+
23
+
24
+ @config_app.command()
25
+ def show() -> None:
26
+ """Display all current settings with values and sources."""
27
+ settings = get_resolved_settings()
28
+ config_path = get_config_path()
29
+
30
+ table = Table(title="Code Explore Configuration")
31
+ table.add_column("Setting", style="cyan", no_wrap=True)
32
+ table.add_column("Value", style="white")
33
+ table.add_column("Source", style="green")
34
+
35
+ for s in settings:
36
+ table.add_row(s.name, s.value, s.source.value)
37
+
38
+ table.add_section()
39
+ path_display = str(config_path) if config_path else "(no config file)"
40
+ table.add_row("Config file", path_display, "")
41
+
42
+ console.print(table)
43
+
44
+
45
+ @config_app.command()
46
+ def init(
47
+ fmt: str = typer.Option("toml", "--format", "-f", help="Config format: toml or yaml"),
48
+ force: bool = typer.Option(False, "--force", help="Overwrite existing config file"),
49
+ ) -> None:
50
+ """Create a configuration file with all default values."""
51
+ config_dir = _get_config_dir()
52
+ ext = "yaml" if fmt in ("yaml", "yml") else "toml"
53
+ path = config_dir / f"config.{ext}"
54
+
55
+ if path.exists() and not force:
56
+ console.print(
57
+ f"[yellow]Config file already exists:[/yellow] {path}\n"
58
+ f"Use [bold]--force[/bold] to overwrite."
59
+ )
60
+ raise typer.Exit(1)
61
+
62
+ write_default_config(path, fmt=ext)
63
+ console.print(f"[green]Created configuration file:[/green] {path}")
64
+
65
+
66
+ @config_app.command()
67
+ def reset(
68
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
69
+ ) -> None:
70
+ """Delete the configuration file and revert to defaults."""
71
+ config_path = _discover_config_file()
72
+
73
+ if not config_path:
74
+ console.print("[yellow]No configuration file found. Already using defaults.[/yellow]")
75
+ raise typer.Exit(0)
76
+
77
+ if not yes:
78
+ confirm = typer.confirm(f"Delete {config_path} and revert to defaults?")
79
+ if not confirm:
80
+ console.print("[dim]Cancelled.[/dim]")
81
+ raise typer.Exit(0)
82
+
83
+ config_path.unlink()
84
+ reset_config()
85
+ console.print(f"[green]Reset configuration to defaults.[/green] Removed: {config_path}")
86
+
87
+
88
+ @config_app.command()
89
+ def path() -> None:
90
+ """Print the path to the active configuration file."""
91
+ config_path = _discover_config_file()
92
+ if config_path:
93
+ console.print(str(config_path))
94
+ else:
95
+ default_path = _get_config_dir() / "config.toml"
96
+ console.print(f"{default_path} [dim](not created yet)[/dim]")
@@ -14,12 +14,14 @@ from rich.tree import Tree
14
14
 
15
15
  from code_explore.database import init_db, save_project, get_project, get_all_projects, get_project_count
16
16
  from code_explore.models import Project, ProjectSource, ProjectStatus
17
+ from code_explore.cli.config_cmd import config_app
17
18
 
18
19
  app = typer.Typer(
19
20
  name="code-explore",
20
21
  help="Personal developer knowledge base - index, analyze and search all your projects.",
21
22
  no_args_is_help=True,
22
23
  )
24
+ app.add_typer(config_app, name="config")
23
25
  console = Console()
24
26
 
25
27
 
@@ -142,9 +144,18 @@ def scan(
142
144
  def search(
143
145
  query: str = typer.Argument(..., help="Search query"),
144
146
  mode: str = typer.Option("hybrid", "--mode", "-m", help="Search mode: fulltext, semantic, or hybrid"),
145
- limit: int = typer.Option(20, "--limit", "-l", help="Maximum results"),
147
+ limit: int = typer.Option(None, "--limit", "-l", help="Maximum results"),
148
+ language: str = typer.Option(None, "--language", "-L", help="Filter by primary language"),
149
+ framework: str = typer.Option(None, "--framework", "-F", help="Filter by framework"),
150
+ pattern: str = typer.Option(None, "--pattern", help="Filter by architectural pattern"),
151
+ tag: str = typer.Option(None, "--tag", "-t", help="Filter by AI-generated tag"),
146
152
  ) -> None:
147
153
  """Search across all indexed projects."""
154
+ from code_explore.config import get_config
155
+ from code_explore.search.filters import apply_filters
156
+
157
+ if limit is None:
158
+ limit = get_config().result_limit
148
159
  init_db()
149
160
 
150
161
  if mode == "fulltext":
@@ -157,6 +168,23 @@ def search(
157
168
  from code_explore.search.hybrid import search as hybrid_search
158
169
  results = hybrid_search(query, limit=limit)
159
170
 
171
+ # Apply post-filters
172
+ results = apply_filters(results, language=language, framework=framework, pattern=pattern, tag=tag)
173
+
174
+ # Show active filters
175
+ active_filters = []
176
+ if language:
177
+ active_filters.append(f"language={language}")
178
+ if framework:
179
+ active_filters.append(f"framework={framework}")
180
+ if pattern:
181
+ active_filters.append(f"pattern={pattern}")
182
+ if tag:
183
+ active_filters.append(f"tag={tag}")
184
+
185
+ if active_filters:
186
+ console.print(f"[dim]Filters: {', '.join(active_filters)}[/dim]")
187
+
160
188
  if not results:
161
189
  console.print(f"[yellow]No results found for:[/yellow] {query}")
162
190
  raise typer.Exit(0)
@@ -276,6 +304,16 @@ def show(
276
304
  if project.concepts:
277
305
  tree.add(f"[bold]Concepts:[/bold] {', '.join(project.concepts)}")
278
306
 
307
+ # AI Tags grouped by category
308
+ if project.ai_tags:
309
+ ai_branch = tree.add("[bold]AI Tags[/bold]")
310
+ by_category: dict[str, list[str]] = {}
311
+ for t in project.ai_tags:
312
+ cat = t.category.value if hasattr(t.category, "value") else str(t.category)
313
+ by_category.setdefault(cat, []).append(t.value)
314
+ for cat, values in sorted(by_category.items()):
315
+ ai_branch.add(f"[magenta]{cat}:[/magenta] {', '.join(values)}")
316
+
279
317
  console.print(Panel(tree, title=f"Project: {project.name}", border_style="cyan"))
280
318
 
281
319
 
@@ -303,22 +341,30 @@ def index(
303
341
  MofNCompleteColumn(),
304
342
  console=console,
305
343
  ) as progress:
306
- summary_task = progress.add_task("Generating summaries...", total=len(projects))
344
+ summary_task = progress.add_task("Generating summaries & AI tags...", total=len(projects))
307
345
  summarized = 0
346
+ tagged = 0
308
347
 
309
348
  for project in projects:
310
- if not project.summary:
349
+ needs_summary = not project.summary
350
+ needs_tags = not project.ai_tags
351
+
352
+ if needs_summary or needs_tags:
311
353
  progress.update(summary_task, description=f"Summarizing [cyan]{project.name}[/cyan]")
312
354
  if model:
313
- summary, tags, concepts = summarize_project(project, model=model)
355
+ summary, tags, concepts, ai_tags = summarize_project(project, model=model)
314
356
  else:
315
- summary, tags, concepts = summarize_project(project)
316
- if summary:
357
+ summary, tags, concepts, ai_tags = summarize_project(project)
358
+ if summary and needs_summary:
317
359
  project.summary = summary
318
360
  project.tags = tags
319
361
  project.concepts = concepts
320
- save_project(project)
321
362
  summarized += 1
363
+ if ai_tags:
364
+ project.ai_tags = ai_tags
365
+ tagged += 1
366
+ if summary or ai_tags:
367
+ save_project(project)
322
368
  progress.update(summary_task, advance=1)
323
369
 
324
370
  embed_task = progress.add_task("Generating embeddings...", total=len(projects))
@@ -334,7 +380,62 @@ def index(
334
380
  indexed += 1
335
381
  progress.update(embed_task, advance=1)
336
382
 
337
- console.print(f"[green]Summarized {summarized} projects, indexed {indexed} projects.[/green]")
383
+ console.print(f"[green]Summarized {summarized}, AI-tagged {tagged}, indexed {indexed} projects.[/green]")
384
+
385
+
386
+ @app.command()
387
+ def tags(
388
+ category: str = typer.Option(None, "--category", "-c", help="Filter by category: domain, technology-role, maturity"),
389
+ ) -> None:
390
+ """List all unique AI tags across projects with counts."""
391
+ init_db()
392
+ projects = get_all_projects()
393
+
394
+ if not projects:
395
+ console.print("[yellow]No projects found. Run 'scan' first.[/yellow]")
396
+ raise typer.Exit(0)
397
+
398
+ # Collect all AI tags with counts
399
+ tag_counts: Counter[str] = Counter()
400
+ tag_categories: dict[str, str] = {}
401
+
402
+ for p in projects:
403
+ for t in p.ai_tags:
404
+ cat = t.category.value if hasattr(t.category, "value") else str(t.category)
405
+ if category and cat != category:
406
+ continue
407
+ tag_counts[t.value] += 1
408
+ tag_categories[t.value] = cat
409
+
410
+ if not tag_counts:
411
+ if category:
412
+ console.print(f"[yellow]No AI tags found for category '{category}'.[/yellow]")
413
+ else:
414
+ console.print("[yellow]No AI tags found. Run 'cex index' to generate tags.[/yellow]")
415
+ raise typer.Exit(0)
416
+
417
+ # Group by category
418
+ by_category: dict[str, list[tuple[str, int]]] = {}
419
+ for tag_value, count in tag_counts.most_common():
420
+ cat = tag_categories[tag_value]
421
+ by_category.setdefault(cat, []).append((tag_value, count))
422
+
423
+ total_tags = len(tag_counts)
424
+ total_projects = len(projects)
425
+ console.print(Panel(
426
+ f"[bold]{total_tags}[/bold] unique AI tags across [bold]{total_projects}[/bold] projects",
427
+ title="AI Tags",
428
+ border_style="magenta",
429
+ ))
430
+
431
+ for cat in sorted(by_category.keys()):
432
+ items = by_category[cat]
433
+ table = Table(title=f"{cat} tags")
434
+ table.add_column("Tag", style="magenta")
435
+ table.add_column("Projects", justify="right", style="yellow")
436
+ for tag_value, count in items:
437
+ table.add_row(tag_value, str(count))
438
+ console.print(table)
338
439
 
339
440
 
340
441
  @app.command()
@@ -342,6 +443,7 @@ def update(
342
443
  force: bool = typer.Option(False, "--force", "-f", help="Re-analyze even if git HEAD unchanged"),
343
444
  reindex: bool = typer.Option(False, "--reindex", help="Regenerate embeddings for updated projects"),
344
445
  resummarize: bool = typer.Option(False, "--resummarize", help="Regenerate AI summaries for updated projects"),
446
+ retag: bool = typer.Option(False, "--retag", help="Regenerate AI tags for updated projects"),
345
447
  ) -> None:
346
448
  """Update existing projects by re-analyzing changed repositories."""
347
449
  from datetime import datetime
@@ -427,20 +529,25 @@ def update(
427
529
  updated += 1
428
530
  progress.update(task, advance=1)
429
531
 
430
- if resummarize and updated_projects:
532
+ if (resummarize or retag) and updated_projects:
431
533
  from code_explore.summarizer.ollama import summarize_project
432
534
 
433
535
  summary_task = progress.add_task("Resummarizing...", total=len(updated_projects))
434
536
  for project in updated_projects:
435
537
  progress.update(summary_task, description=f"Summarizing [cyan]{project.name}[/cyan]")
436
- project.summary = None
437
- project.tags = []
438
- project.concepts = []
439
- summary, tags, concepts = summarize_project(project)
440
- if summary:
538
+ if resummarize:
539
+ project.summary = None
540
+ project.tags = []
541
+ project.concepts = []
542
+ if retag:
543
+ project.ai_tags = []
544
+ summary, tags, concepts, ai_tags = summarize_project(project)
545
+ if summary and resummarize:
441
546
  project.summary = summary
442
547
  project.tags = tags
443
548
  project.concepts = concepts
549
+ if ai_tags and retag:
550
+ project.ai_tags = ai_tags
444
551
  save_project(project)
445
552
  progress.update(summary_task, advance=1)
446
553
 
@@ -541,12 +648,13 @@ def serve(
541
648
  host: str = typer.Option("0.0.0.0", "--host", "-h", help="Bind host"),
542
649
  port: int = typer.Option(8000, "--port", "-p", help="Bind port"),
543
650
  ) -> None:
544
- """Start the FastAPI server."""
651
+ """Start the FastAPI server with web dashboard."""
545
652
  import uvicorn
546
653
 
547
654
  init_db()
548
655
  console.print(Panel(
549
656
  f"Starting server on [bold cyan]http://{host}:{port}[/bold cyan]\n"
657
+ f"Dashboard at [bold cyan]http://{host}:{port}[/bold cyan]\n"
550
658
  f"API docs at [bold cyan]http://{host}:{port}/docs[/bold cyan]",
551
659
  title="Code Explore API",
552
660
  ))