attocode-code-intel 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.
- attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
- attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
- attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
- attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
- attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
- attocode_intel/GUIDELINES.md +432 -0
- attocode_intel/__init__.py +52 -0
- attocode_intel/_internal/__init__.py +1 -0
- attocode_intel/_internal/errors/__init__.py +126 -0
- attocode_intel/_internal/integrations/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
- attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
- attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
- attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
- attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
- attocode_intel/_internal/integrations/context/cas.py +466 -0
- attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
- attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
- attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
- attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
- attocode_intel/_internal/integrations/context/cross_references.py +616 -0
- attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
- attocode_intel/_internal/integrations/context/embeddings.py +282 -0
- attocode_intel/_internal/integrations/context/frecency.py +427 -0
- attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
- attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
- attocode_intel/_internal/integrations/context/hydration.py +90 -0
- attocode_intel/_internal/integrations/context/index_repair.py +87 -0
- attocode_intel/_internal/integrations/context/index_store.py +586 -0
- attocode_intel/_internal/integrations/context/memory_store.py +602 -0
- attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
- attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
- attocode_intel/_internal/integrations/context/query_history.py +417 -0
- attocode_intel/_internal/integrations/context/reranker.py +351 -0
- attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
- attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
- attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
- attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
- attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
- attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
- attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
- attocode_intel/_internal/integrations/context/vector_store.py +795 -0
- attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
- attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
- attocode_intel/_internal/integrations/lsp/client.py +1140 -0
- attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
- attocode_intel/_internal/integrations/mcp/client.py +253 -0
- attocode_intel/_internal/integrations/security/__init__.py +1 -0
- attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
- attocode_intel/_internal/integrations/security/dataflow.py +522 -0
- attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
- attocode_intel/_internal/integrations/security/matcher.py +44 -0
- attocode_intel/_internal/integrations/security/patterns.py +724 -0
- attocode_intel/_internal/integrations/security/scanner.py +351 -0
- attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
- attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
- attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
- attocode_intel/_internal/tricks/__init__.py +1 -0
- attocode_intel/_internal/tricks/recursive_context.py +357 -0
- attocode_intel/_internal/types/__init__.py +1 -0
- attocode_intel/_internal/types/events.py +289 -0
- attocode_intel/_internal/types/severity.py +15 -0
- attocode_intel/_shared.py +341 -0
- attocode_intel/analysis_coverage.py +56 -0
- attocode_intel/api/__init__.py +1 -0
- attocode_intel/api/access.py +32 -0
- attocode_intel/api/app.py +247 -0
- attocode_intel/api/auth/__init__.py +128 -0
- attocode_intel/api/auth/api_keys.py +61 -0
- attocode_intel/api/auth/context.py +34 -0
- attocode_intel/api/auth/jwt.py +66 -0
- attocode_intel/api/auth/oauth.py +146 -0
- attocode_intel/api/auth/passwords.py +15 -0
- attocode_intel/api/deps.py +351 -0
- attocode_intel/api/knowledge_bridge.py +13 -0
- attocode_intel/api/mcp_transport.py +51 -0
- attocode_intel/api/middleware.py +411 -0
- attocode_intel/api/models.py +698 -0
- attocode_intel/api/providers/__init__.py +1 -0
- attocode_intel/api/providers/db_provider.py +1106 -0
- attocode_intel/api/providers/local_provider.py +476 -0
- attocode_intel/api/providers/remote_provider.py +740 -0
- attocode_intel/api/repository_source.py +21 -0
- attocode_intel/api/routes/__init__.py +1 -0
- attocode_intel/api/routes/_pin_helper.py +158 -0
- attocode_intel/api/routes/activity.py +148 -0
- attocode_intel/api/routes/adr.py +102 -0
- attocode_intel/api/routes/analysis.py +515 -0
- attocode_intel/api/routes/api_keys.py +170 -0
- attocode_intel/api/routes/auth.py +428 -0
- attocode_intel/api/routes/branches.py +262 -0
- attocode_intel/api/routes/cross_repo_search.py +198 -0
- attocode_intel/api/routes/embeddings.py +386 -0
- attocode_intel/api/routes/files.py +132 -0
- attocode_intel/api/routes/files_v2.py +388 -0
- attocode_intel/api/routes/gc.py +322 -0
- attocode_intel/api/routes/git_v2.py +433 -0
- attocode_intel/api/routes/graph.py +174 -0
- attocode_intel/api/routes/graph_viz.py +160 -0
- attocode_intel/api/routes/health.py +62 -0
- attocode_intel/api/routes/history.py +175 -0
- attocode_intel/api/routes/jobs.py +84 -0
- attocode_intel/api/routes/learning.py +205 -0
- attocode_intel/api/routes/lsp.py +272 -0
- attocode_intel/api/routes/notify.py +623 -0
- attocode_intel/api/routes/operations.py +27 -0
- attocode_intel/api/routes/orgs.py +924 -0
- attocode_intel/api/routes/preferences.py +100 -0
- attocode_intel/api/routes/presence.py +42 -0
- attocode_intel/api/routes/projects.py +116 -0
- attocode_intel/api/routes/repos.py +47 -0
- attocode_intel/api/routes/search.py +180 -0
- attocode_intel/api/routes/snapshots.py +614 -0
- attocode_intel/api/routes/webhooks.py +313 -0
- attocode_intel/api/routes/websocket.py +116 -0
- attocode_intel/api/static/graph.html +239 -0
- attocode_intel/api/utils.py +96 -0
- attocode_intel/artifacts/__init__.py +45 -0
- attocode_intel/artifacts/hashing.py +230 -0
- attocode_intel/artifacts/provenance.py +190 -0
- attocode_intel/artifacts/retrieval_pin.py +159 -0
- attocode_intel/audit.py +59 -0
- attocode_intel/bug_finder.py +206 -0
- attocode_intel/bundle.py +80 -0
- attocode_intel/catalog.py +242 -0
- attocode_intel/change_review.py +75 -0
- attocode_intel/cli.py +3062 -0
- attocode_intel/community.py +63 -0
- attocode_intel/confidence/__init__.py +144 -0
- attocode_intel/confidence/jev.py +234 -0
- attocode_intel/confidence/llm.py +330 -0
- attocode_intel/confidence/redact.py +44 -0
- attocode_intel/confidence/settings.py +74 -0
- attocode_intel/config.py +256 -0
- attocode_intel/crypto.py +31 -0
- attocode_intel/db/__init__.py +7 -0
- attocode_intel/db/base.py +33 -0
- attocode_intel/db/engine.py +61 -0
- attocode_intel/db/models.py +529 -0
- attocode_intel/entrypoint.py +185 -0
- attocode_intel/focused_evidence.py +130 -0
- attocode_intel/freshness.py +154 -0
- attocode_intel/gateway.py +734 -0
- attocode_intel/git/__init__.py +7 -0
- attocode_intel/git/credentials.py +75 -0
- attocode_intel/git/manager.py +493 -0
- attocode_intel/git/models.py +96 -0
- attocode_intel/git/storage.py +98 -0
- attocode_intel/graph_query_parser.py +761 -0
- attocode_intel/helpers.py +712 -0
- attocode_intel/indexing/__init__.py +29 -0
- attocode_intel/indexing/debouncer.py +136 -0
- attocode_intel/indexing/delta_indexer.py +151 -0
- attocode_intel/indexing/full_indexer.py +323 -0
- attocode_intel/indexing/incremental.py +240 -0
- attocode_intel/indexing/parser.py +403 -0
- attocode_intel/indexing/summarizer.py +264 -0
- attocode_intel/installer.py +1896 -0
- attocode_intel/knowledge.py +269 -0
- attocode_intel/local_knowledge.py +77 -0
- attocode_intel/migrations/alembic.ini +36 -0
- attocode_intel/migrations/env.py +71 -0
- attocode_intel/migrations/script.py.mako +27 -0
- attocode_intel/migrations/versions/001_initial_schema.py +219 -0
- attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
- attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
- attocode_intel/migrations/versions/004_audit_events.py +38 -0
- attocode_intel/migrations/versions/005_user_preferences.py +30 -0
- attocode_intel/migrations/versions/006_google_oauth.py +27 -0
- attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
- attocode_intel/migrations/versions/008_symbol_references.py +37 -0
- attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
- attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
- attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
- attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
- attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
- attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
- attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
- attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
- attocode_intel/migrations/versions/017_provenance_table.py +102 -0
- attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
- attocode_intel/migrations/versions/019_symref_caller.py +42 -0
- attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
- attocode_intel/onboarding.py +258 -0
- attocode_intel/output.py +178 -0
- attocode_intel/precision.py +133 -0
- attocode_intel/presence.py +160 -0
- attocode_intel/probe.py +110 -0
- attocode_intel/project_dir.py +97 -0
- attocode_intel/pubsub.py +113 -0
- attocode_intel/query_ranking.py +242 -0
- attocode_intel/readiness.py +972 -0
- attocode_intel/redis.py +51 -0
- attocode_intel/reference_pages.py +60 -0
- attocode_intel/remote.py +131 -0
- attocode_intel/repo_ranker.py +246 -0
- attocode_intel/request_context.py +82 -0
- attocode_intel/rules/__init__.py +28 -0
- attocode_intel/rules/ci.py +320 -0
- attocode_intel/rules/combinators.py +293 -0
- attocode_intel/rules/enricher.py +127 -0
- attocode_intel/rules/evolution.py +452 -0
- attocode_intel/rules/executor.py +531 -0
- attocode_intel/rules/filters/__init__.py +5 -0
- attocode_intel/rules/filters/pipeline.py +151 -0
- attocode_intel/rules/formatter.py +198 -0
- attocode_intel/rules/hygiene.py +242 -0
- attocode_intel/rules/importers/__init__.py +1 -0
- attocode_intel/rules/importers/semgrep.py +382 -0
- attocode_intel/rules/loader.py +292 -0
- attocode_intel/rules/marketplace.py +375 -0
- attocode_intel/rules/metavar.py +181 -0
- attocode_intel/rules/model.py +317 -0
- attocode_intel/rules/packs/__init__.py +1 -0
- attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
- attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
- attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
- attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
- attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
- attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
- attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
- attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
- attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
- attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
- attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
- attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
- attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
- attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
- attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
- attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
- attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
- attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
- attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
- attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
- attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
- attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
- attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
- attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
- attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
- attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
- attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
- attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
- attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
- attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
- attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
- attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
- attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/pack_loader.py +244 -0
- attocode_intel/rules/plugins/__init__.py +1 -0
- attocode_intel/rules/plugins/plugin_loader.py +132 -0
- attocode_intel/rules/profiling.py +332 -0
- attocode_intel/rules/registry.py +213 -0
- attocode_intel/rules/sarif.py +187 -0
- attocode_intel/rules/synthesis.py +420 -0
- attocode_intel/rules/testing.py +326 -0
- attocode_intel/server.py +734 -0
- attocode_intel/service.py +3416 -0
- attocode_intel/sharing.py +57 -0
- attocode_intel/storage/__init__.py +6 -0
- attocode_intel/storage/branch_overlay.py +352 -0
- attocode_intel/storage/content_store.py +220 -0
- attocode_intel/storage/dependency_store.py +143 -0
- attocode_intel/storage/diff_engine.py +187 -0
- attocode_intel/storage/embedding_store.py +594 -0
- attocode_intel/storage/provenance_store.py +137 -0
- attocode_intel/storage/security_scanner_db.py +164 -0
- attocode_intel/storage/symbol_store.py +160 -0
- attocode_intel/structural_evidence.py +144 -0
- attocode_intel/symbol_inspection.py +141 -0
- attocode_intel/symbol_links.py +167 -0
- attocode_intel/telemetry.py +42 -0
- attocode_intel/test_ranking.py +73 -0
- attocode_intel/testing/__init__.py +43 -0
- attocode_intel/testing/fixtures.py +122 -0
- attocode_intel/testing/helpers.py +75 -0
- attocode_intel/testing/mocks.py +99 -0
- attocode_intel/tools/__init__.py +2 -0
- attocode_intel/tools/adr_tools.py +628 -0
- attocode_intel/tools/analysis_tools.py +382 -0
- attocode_intel/tools/composite_tools.py +695 -0
- attocode_intel/tools/cross_mode_tools.py +173 -0
- attocode_intel/tools/dead_code_tools.py +582 -0
- attocode_intel/tools/distill_tools.py +353 -0
- attocode_intel/tools/frecency_tools.py +220 -0
- attocode_intel/tools/fuzzy_tools.py +255 -0
- attocode_intel/tools/history_tools.py +486 -0
- attocode_intel/tools/learning_tools.py +222 -0
- attocode_intel/tools/lsp_tools.py +218 -0
- attocode_intel/tools/maintenance_tools.py +1662 -0
- attocode_intel/tools/navigation_tools.py +220 -0
- attocode_intel/tools/overlay_tools.py +540 -0
- attocode_intel/tools/pin_store.py +432 -0
- attocode_intel/tools/pin_tools.py +266 -0
- attocode_intel/tools/query_constraints_tools.py +262 -0
- attocode_intel/tools/query_history_tools.py +158 -0
- attocode_intel/tools/readiness_tools.py +44 -0
- attocode_intel/tools/rule_tools.py +992 -0
- attocode_intel/tools/search_tools.py +770 -0
- attocode_intel/tools/snapshot_tools.py +624 -0
- attocode_intel/workers/__init__.py +1 -0
- attocode_intel/workers/job_utils.py +111 -0
- attocode_intel/workers/jobs.py +573 -0
- attocode_intel/workers/run.py +30 -0
- attocode_intel/workers/settings.py +61 -0
attocode_intel/cli.py
ADDED
|
@@ -0,0 +1,3062 @@
|
|
|
1
|
+
"""CLI subcommands for attocode code-intel.
|
|
2
|
+
|
|
3
|
+
Dispatched from the main CLI when the first positional argument is "code-intel".
|
|
4
|
+
|
|
5
|
+
Usage::
|
|
6
|
+
|
|
7
|
+
attocode code-intel install claude [--global] [--project .]
|
|
8
|
+
attocode code-intel install codex [--global] [--project .]
|
|
9
|
+
attocode code-intel uninstall claude
|
|
10
|
+
attocode code-intel serve [--project .]
|
|
11
|
+
attocode code-intel status
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dispatch_code_intel(parts: tuple[str, ...] | list[str], *, debug: bool = False) -> None:
|
|
22
|
+
"""Dispatch code-intel subcommands.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
parts: Remaining CLI arguments after "code-intel".
|
|
26
|
+
debug: Whether debug mode is enabled.
|
|
27
|
+
"""
|
|
28
|
+
args = list(parts)
|
|
29
|
+
|
|
30
|
+
if not args or args[0] in ("--help", "-h", "help", "commands"):
|
|
31
|
+
_print_help()
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
cmd = args[0]
|
|
35
|
+
|
|
36
|
+
if cmd == "install":
|
|
37
|
+
_cmd_install(args[1:])
|
|
38
|
+
elif cmd == "uninstall":
|
|
39
|
+
_cmd_uninstall(args[1:])
|
|
40
|
+
elif cmd == "serve":
|
|
41
|
+
_cmd_serve(args[1:], debug=debug)
|
|
42
|
+
elif cmd == "status":
|
|
43
|
+
_cmd_status()
|
|
44
|
+
elif cmd == "probe-install":
|
|
45
|
+
_cmd_probe_install(args[1:])
|
|
46
|
+
elif cmd == "notify":
|
|
47
|
+
_cmd_notify(args[1:])
|
|
48
|
+
elif cmd == "index":
|
|
49
|
+
_cmd_index(args[1:])
|
|
50
|
+
elif cmd == "connect":
|
|
51
|
+
_cmd_connect(args[1:])
|
|
52
|
+
elif cmd == "test-connection":
|
|
53
|
+
_cmd_test_connection(args[1:])
|
|
54
|
+
elif cmd == "watch":
|
|
55
|
+
_cmd_watch(args[1:])
|
|
56
|
+
elif cmd == "setup":
|
|
57
|
+
_cmd_setup(args[1:])
|
|
58
|
+
elif cmd == "query":
|
|
59
|
+
_cmd_query(args[1:])
|
|
60
|
+
elif cmd == "symbols":
|
|
61
|
+
_cmd_symbols(args[1:])
|
|
62
|
+
elif cmd == "impact":
|
|
63
|
+
_cmd_impact(args[1:])
|
|
64
|
+
elif cmd == "hotspots":
|
|
65
|
+
_cmd_hotspots(args[1:])
|
|
66
|
+
elif cmd == "deps":
|
|
67
|
+
_cmd_deps(args[1:])
|
|
68
|
+
elif cmd == "gc":
|
|
69
|
+
_cmd_gc(args[1:])
|
|
70
|
+
elif cmd == "verify":
|
|
71
|
+
_cmd_verify(args[1:])
|
|
72
|
+
elif cmd == "reindex":
|
|
73
|
+
_cmd_reindex(args[1:])
|
|
74
|
+
elif cmd == "bundle":
|
|
75
|
+
_cmd_bundle(args[1:])
|
|
76
|
+
else:
|
|
77
|
+
print(f"Unknown code-intel command: {cmd}", file=sys.stderr)
|
|
78
|
+
_print_help()
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _print_help() -> None:
|
|
83
|
+
print(
|
|
84
|
+
"Usage: attocode-code-intel <command>\n"
|
|
85
|
+
"\n"
|
|
86
|
+
"Commands:\n"
|
|
87
|
+
" init Add the server to Codex, Claude Code or Cursor\n"
|
|
88
|
+
" doctor Start the configured server and check that it answers\n"
|
|
89
|
+
" install <target> Install MCP server into a coding assistant\n"
|
|
90
|
+
" uninstall <target> Remove MCP server from a coding assistant\n"
|
|
91
|
+
" connect Connect project to a running code-intel server\n"
|
|
92
|
+
" serve Run MCP server directly (stdio or SSE)\n"
|
|
93
|
+
" index Build or check embedding index for semantic search\n"
|
|
94
|
+
" status Check installation status across all targets\n"
|
|
95
|
+
" probe-install Run a runtime MCP probe for an installed target\n"
|
|
96
|
+
" notify Notify server about changed files (for hooks)\n"
|
|
97
|
+
" test-connection Verify connectivity to the remote server\n"
|
|
98
|
+
" watch Watch filesystem for changes and notify remote server\n"
|
|
99
|
+
" setup [Dev] Bootstrap local infrastructure for development\n"
|
|
100
|
+
"\n"
|
|
101
|
+
"Query commands:\n"
|
|
102
|
+
" query <text> Semantic search across the codebase\n"
|
|
103
|
+
" symbols <file> List symbols in a file (or --search <name>)\n"
|
|
104
|
+
" impact <file> ... Show blast radius of file changes\n"
|
|
105
|
+
" hotspots Show risk/complexity hotspots\n"
|
|
106
|
+
" deps <file> Show file dependencies and dependents\n"
|
|
107
|
+
"\n"
|
|
108
|
+
"Maintenance commands:\n"
|
|
109
|
+
" bundle export Export local code-intel artifacts into a bundle\n"
|
|
110
|
+
" bundle inspect Inspect bundle metadata and artifacts\n"
|
|
111
|
+
" gc Run garbage collection (orphaned embeddings + content)\n"
|
|
112
|
+
" verify Run integrity checks on the index\n"
|
|
113
|
+
" reindex Force a full reindex of the project\n"
|
|
114
|
+
"\n"
|
|
115
|
+
"Targets (auto-install):\n"
|
|
116
|
+
" claude Claude Code (via `claude mcp add` CLI)\n"
|
|
117
|
+
" cursor Cursor (.cursor/mcp.json)\n"
|
|
118
|
+
" windsurf Windsurf (.windsurf/mcp.json)\n"
|
|
119
|
+
" vscode VS Code / GitHub Copilot (.vscode/mcp.json)\n"
|
|
120
|
+
" codex OpenAI Codex CLI (.codex/config.toml)\n"
|
|
121
|
+
" claude-desktop Claude Desktop (platform-specific config)\n"
|
|
122
|
+
" cline Cline VS Code extension (globalStorage)\n"
|
|
123
|
+
" zed Zed (.zed/settings.json)\n"
|
|
124
|
+
" opencode OpenCode (~/.config/opencode/config.json)\n"
|
|
125
|
+
" gemini-cli Gemini CLI (.gemini/settings.json)\n"
|
|
126
|
+
" roo-code Roo Code (.roo/mcp.json)\n"
|
|
127
|
+
" amazon-q Amazon Q Developer (~/.aws/amazonq/mcp.json)\n"
|
|
128
|
+
" copilot-cli GitHub Copilot CLI (~/.copilot/mcp-config.json)\n"
|
|
129
|
+
" junie Junie / JetBrains (.junie/mcp/mcp.json)\n"
|
|
130
|
+
" kiro Kiro (.kiro/settings/mcp.json)\n"
|
|
131
|
+
" trae Trae (.trae/mcp.json)\n"
|
|
132
|
+
" firebase Firebase Studio (.idx/mcp.json)\n"
|
|
133
|
+
" amp Amp / Sourcegraph (.amp/settings.json)\n"
|
|
134
|
+
" continue Continue.dev (.continue/mcp.json)\n"
|
|
135
|
+
" hermes Hermes Agent (~/.hermes/config.yaml)\n"
|
|
136
|
+
" goose Goose (~/.config/goose/config.yaml)\n"
|
|
137
|
+
"\n"
|
|
138
|
+
"Targets (manual instructions):\n"
|
|
139
|
+
" intellij IntelliJ IDEA (prints setup steps)\n"
|
|
140
|
+
"\n"
|
|
141
|
+
"Options:\n"
|
|
142
|
+
" --project <path> Project directory to index (default: .)\n"
|
|
143
|
+
" --global Install globally (Claude, Codex, Zed, Gemini, Junie, Amp)\n"
|
|
144
|
+
" --hooks Also install PostToolUse hooks (Claude Code)\n"
|
|
145
|
+
"\n"
|
|
146
|
+
"Probe options:\n"
|
|
147
|
+
" probe-install <t> Probe an installed file-based target by launching MCP stdio\n"
|
|
148
|
+
" --project <path> Project directory to substitute for ${workspaceFolder}\n"
|
|
149
|
+
" --global Read the user/global config when the target supports it\n"
|
|
150
|
+
"\n"
|
|
151
|
+
"Serve options:\n"
|
|
152
|
+
" --transport <type> Transport protocol: stdio (default), sse, or http\n"
|
|
153
|
+
" --host <addr> Server host address (default: 127.0.0.1)\n"
|
|
154
|
+
" --port <num> Server port number (default: 8080)\n"
|
|
155
|
+
" --no-watch Disable filesystem watcher (watcher is on by default)\n"
|
|
156
|
+
" --watch-debounce N File-change debounce in milliseconds (default: 500)\n"
|
|
157
|
+
" --local-only Disable remote auto-load from .attocode/config.toml\n"
|
|
158
|
+
"\n"
|
|
159
|
+
"Notify options:\n"
|
|
160
|
+
" --file <path> File that changed (repeatable)\n"
|
|
161
|
+
" --stdin Read changed file paths from stdin (JSON or lines)\n"
|
|
162
|
+
"\n"
|
|
163
|
+
"Connect options:\n"
|
|
164
|
+
" --server <url> Remote server URL (e.g. https://code.example.com)\n"
|
|
165
|
+
" --token <token> JWT or API key (skip interactive login)\n"
|
|
166
|
+
" --email <email> Email for register/login (skip prompt)\n"
|
|
167
|
+
" --password <pass> Password for register/login (skip prompt)\n"
|
|
168
|
+
" --org <slug-or-id> Organization slug or ID (skip selection)\n"
|
|
169
|
+
" --repo <repo_id> Repository UUID on the remote server\n"
|
|
170
|
+
" --name <repo-name> Override auto-detected repository name\n"
|
|
171
|
+
"\n"
|
|
172
|
+
"Watch options:\n"
|
|
173
|
+
" --debounce <ms> Debounce interval in milliseconds (default: 500)\n"
|
|
174
|
+
"\n"
|
|
175
|
+
"Setup options (dev):\n"
|
|
176
|
+
" --reset Wipe Docker volumes and dev state, then re-bootstrap\n"
|
|
177
|
+
" --skip-deps Skip uv sync (use if deps already installed)\n"
|
|
178
|
+
" --project <path> Project directory (default: .)\n"
|
|
179
|
+
"\n"
|
|
180
|
+
"Query options:\n"
|
|
181
|
+
" --top <N> Number of results (query: default 10, hotspots: default 15)\n"
|
|
182
|
+
" --filter <glob> File filter glob for semantic search (e.g. '*.py')\n"
|
|
183
|
+
" --search <name> Search for symbol by name (symbols command)\n"
|
|
184
|
+
"\n"
|
|
185
|
+
"Bundle options:\n"
|
|
186
|
+
" bundle export --output <bundle.tar.gz> [--project <path>]\n"
|
|
187
|
+
" bundle inspect <bundle.tar.gz>\n"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _parse_opts(args: list[str]) -> tuple[str | None, str, str, bool]:
|
|
192
|
+
"""Parse target, --project, --global, and --hooks from args.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
(target, project_dir, scope, hooks)
|
|
196
|
+
"""
|
|
197
|
+
target = None
|
|
198
|
+
project_dir = "."
|
|
199
|
+
scope = "local"
|
|
200
|
+
hooks = False
|
|
201
|
+
|
|
202
|
+
i = 0
|
|
203
|
+
while i < len(args):
|
|
204
|
+
arg = args[i]
|
|
205
|
+
if arg == "--project" and i + 1 < len(args):
|
|
206
|
+
project_dir = args[i + 1]
|
|
207
|
+
i += 2
|
|
208
|
+
elif arg.startswith("--project="):
|
|
209
|
+
project_dir = arg.split("=", 1)[1]
|
|
210
|
+
i += 1
|
|
211
|
+
elif arg == "--global":
|
|
212
|
+
scope = "user"
|
|
213
|
+
i += 1
|
|
214
|
+
elif arg == "--hooks":
|
|
215
|
+
hooks = True
|
|
216
|
+
i += 1
|
|
217
|
+
elif not arg.startswith("-") and target is None:
|
|
218
|
+
target = arg
|
|
219
|
+
i += 1
|
|
220
|
+
else:
|
|
221
|
+
i += 1
|
|
222
|
+
|
|
223
|
+
return target, project_dir, scope, hooks
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _args_include_project_flag(args: list[str]) -> bool:
|
|
227
|
+
return any(arg == "--project" or arg.startswith("--project=") for arg in args)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _cmd_install(args: list[str]) -> None:
|
|
231
|
+
from attocode_intel.installer import ALL_TARGETS_STR, install, install_hooks
|
|
232
|
+
|
|
233
|
+
target, project_dir, scope, hooks = _parse_opts(args)
|
|
234
|
+
if not target:
|
|
235
|
+
print(f"Error: specify a target ({ALL_TARGETS_STR})", file=sys.stderr)
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
|
|
238
|
+
success = install(target, project_dir=project_dir, scope=scope)
|
|
239
|
+
if not success:
|
|
240
|
+
sys.exit(1)
|
|
241
|
+
|
|
242
|
+
if hooks:
|
|
243
|
+
install_hooks(target, project_dir=project_dir)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _cmd_uninstall(args: list[str]) -> None:
|
|
247
|
+
from attocode_intel.installer import ALL_TARGETS_STR, uninstall, uninstall_hooks
|
|
248
|
+
|
|
249
|
+
target, project_dir, scope, _hooks = _parse_opts(args)
|
|
250
|
+
if not target:
|
|
251
|
+
print(f"Error: specify a target ({ALL_TARGETS_STR})", file=sys.stderr)
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
|
|
254
|
+
# Always attempt to remove hooks on uninstall
|
|
255
|
+
uninstall_hooks(target, project_dir=project_dir)
|
|
256
|
+
|
|
257
|
+
success = uninstall(target, project_dir=project_dir, scope=scope)
|
|
258
|
+
if not success:
|
|
259
|
+
sys.exit(1)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _cmd_serve(args: list[str], *, debug: bool = False) -> None:
|
|
263
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
264
|
+
|
|
265
|
+
# Parse transport flags and watcher options
|
|
266
|
+
transport = "stdio"
|
|
267
|
+
host = "127.0.0.1"
|
|
268
|
+
port = 8080
|
|
269
|
+
no_watch = False
|
|
270
|
+
watch_debounce = 500
|
|
271
|
+
local_only = False
|
|
272
|
+
i = 0
|
|
273
|
+
while i < len(args):
|
|
274
|
+
arg = args[i]
|
|
275
|
+
if arg == "--transport" and i + 1 < len(args):
|
|
276
|
+
transport = args[i + 1]
|
|
277
|
+
i += 2
|
|
278
|
+
elif arg.startswith("--transport="):
|
|
279
|
+
transport = arg.split("=", 1)[1]
|
|
280
|
+
i += 1
|
|
281
|
+
elif arg == "--host" and i + 1 < len(args):
|
|
282
|
+
host = args[i + 1]
|
|
283
|
+
i += 2
|
|
284
|
+
elif arg.startswith("--host="):
|
|
285
|
+
host = arg.split("=", 1)[1]
|
|
286
|
+
i += 1
|
|
287
|
+
elif arg == "--port" and i + 1 < len(args):
|
|
288
|
+
port = int(args[i + 1])
|
|
289
|
+
i += 2
|
|
290
|
+
elif arg.startswith("--port="):
|
|
291
|
+
port = int(arg.split("=", 1)[1])
|
|
292
|
+
i += 1
|
|
293
|
+
elif arg == "--no-watch":
|
|
294
|
+
no_watch = True
|
|
295
|
+
i += 1
|
|
296
|
+
elif arg == "--watch-debounce" and i + 1 < len(args):
|
|
297
|
+
watch_debounce = int(args[i + 1])
|
|
298
|
+
i += 2
|
|
299
|
+
elif arg.startswith("--watch-debounce="):
|
|
300
|
+
watch_debounce = int(arg.split("=", 1)[1])
|
|
301
|
+
i += 1
|
|
302
|
+
elif arg == "--local-only":
|
|
303
|
+
local_only = True
|
|
304
|
+
i += 1
|
|
305
|
+
else:
|
|
306
|
+
i += 1
|
|
307
|
+
|
|
308
|
+
abs_dir = os.path.abspath(project_dir)
|
|
309
|
+
os.environ["ATTOCODE_PROJECT_DIR"] = abs_dir
|
|
310
|
+
|
|
311
|
+
# Remote config handling — shared with server.py main() so both startup
|
|
312
|
+
# paths behave identically (config + env, health/expiry-gated fallback).
|
|
313
|
+
from attocode_intel._shared import enable_remote_if_configured
|
|
314
|
+
if local_only:
|
|
315
|
+
os.environ["ATTOCODE_LOCAL_ONLY"] = "1"
|
|
316
|
+
enable_remote_if_configured(abs_dir, local_only=local_only)
|
|
317
|
+
|
|
318
|
+
if debug:
|
|
319
|
+
import logging
|
|
320
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
321
|
+
|
|
322
|
+
from attocode_intel.server import (
|
|
323
|
+
_start_file_watcher,
|
|
324
|
+
_start_queue_poller,
|
|
325
|
+
_stop_file_watcher,
|
|
326
|
+
mcp,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
print(f"Starting attocode-code-intel server for {abs_dir} (transport={transport})", file=sys.stderr)
|
|
330
|
+
|
|
331
|
+
# Start file watcher and notification queue poller for stdio/sse transports.
|
|
332
|
+
# (HTTP transport manages its own lifecycle via the FastAPI app.)
|
|
333
|
+
if transport != "http" and not no_watch:
|
|
334
|
+
_start_file_watcher(abs_dir, debounce_ms=watch_debounce)
|
|
335
|
+
_start_queue_poller(abs_dir)
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
if transport == "http":
|
|
339
|
+
_serve_http(abs_dir, host=host, port=port, debug=debug)
|
|
340
|
+
elif transport == "sse":
|
|
341
|
+
mcp.run(transport="sse", host=host, port=port)
|
|
342
|
+
else:
|
|
343
|
+
mcp.run(transport="stdio")
|
|
344
|
+
finally:
|
|
345
|
+
if transport != "http" and not no_watch:
|
|
346
|
+
_stop_file_watcher()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _serve_http(project_dir: str, *, host: str, port: int, debug: bool) -> None:
|
|
350
|
+
"""Start the FastAPI HTTP server."""
|
|
351
|
+
from attocode_intel.config import CodeIntelConfig, is_loopback
|
|
352
|
+
|
|
353
|
+
config = CodeIntelConfig.from_env()
|
|
354
|
+
# Service mode signs in its users. Local mode checks only ATTOCODE_API_KEY, and without it
|
|
355
|
+
# any computer that reaches the port can read the code index.
|
|
356
|
+
if not (is_loopback(host) or config.api_key or config.database_url):
|
|
357
|
+
print(
|
|
358
|
+
f"Error: --host {host} lets other computers read this code index. "
|
|
359
|
+
"Set ATTOCODE_API_KEY, or use --host 127.0.0.1.",
|
|
360
|
+
file=sys.stderr,
|
|
361
|
+
)
|
|
362
|
+
sys.exit(1)
|
|
363
|
+
try:
|
|
364
|
+
import uvicorn
|
|
365
|
+
except ImportError:
|
|
366
|
+
print(
|
|
367
|
+
"Error: the HTTP server needs the http extra. "
|
|
368
|
+
"Install it with: uv tool install 'attocode-code-intel[http]'",
|
|
369
|
+
file=sys.stderr,
|
|
370
|
+
)
|
|
371
|
+
sys.exit(1)
|
|
372
|
+
|
|
373
|
+
from attocode_intel.api.app import create_app
|
|
374
|
+
|
|
375
|
+
config.project_dir = project_dir
|
|
376
|
+
config.host = host
|
|
377
|
+
config.port = port
|
|
378
|
+
if debug:
|
|
379
|
+
config.log_level = "debug"
|
|
380
|
+
app = create_app(config)
|
|
381
|
+
uvicorn.run(app, host=host, port=port, log_level=config.log_level)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _cmd_status() -> None:
|
|
385
|
+
"""Check if the MCP server is installed in known targets."""
|
|
386
|
+
import json
|
|
387
|
+
from pathlib import Path
|
|
388
|
+
|
|
389
|
+
from attocode_intel.installer import _get_user_config_dir
|
|
390
|
+
|
|
391
|
+
print("attocode-code-intel status:\n")
|
|
392
|
+
|
|
393
|
+
# Check Claude Code
|
|
394
|
+
has_claude = shutil.which("claude") is not None
|
|
395
|
+
if has_claude:
|
|
396
|
+
try:
|
|
397
|
+
result = __import__("subprocess").run(
|
|
398
|
+
["claude", "mcp", "list"],
|
|
399
|
+
capture_output=True, text=True, timeout=10,
|
|
400
|
+
)
|
|
401
|
+
if "attocode-code-intel" in result.stdout:
|
|
402
|
+
print(" Claude Code: installed")
|
|
403
|
+
else:
|
|
404
|
+
print(" Claude Code: not installed")
|
|
405
|
+
except Exception:
|
|
406
|
+
print(" Claude Code: unable to check")
|
|
407
|
+
else:
|
|
408
|
+
print(" Claude Code: CLI not found")
|
|
409
|
+
|
|
410
|
+
# Check JSON-config targets (Cursor, Windsurf, VS Code)
|
|
411
|
+
for name, config_dir in [("Cursor", ".cursor"), ("Windsurf", ".windsurf"), ("VS Code", ".vscode")]:
|
|
412
|
+
cfg = Path(config_dir) / "mcp.json"
|
|
413
|
+
if cfg.exists():
|
|
414
|
+
try:
|
|
415
|
+
data = json.loads(cfg.read_text())
|
|
416
|
+
if "attocode-code-intel" in data.get("mcpServers", {}):
|
|
417
|
+
print(f" {name}: installed")
|
|
418
|
+
else:
|
|
419
|
+
print(f" {name}: not installed")
|
|
420
|
+
except Exception:
|
|
421
|
+
print(f" {name}: unable to check")
|
|
422
|
+
else:
|
|
423
|
+
print(f" {name}: not installed")
|
|
424
|
+
|
|
425
|
+
# GSD — project `.gsd/mcp.json` and user `~/.gsd/mcp.json` use ``servers`` key
|
|
426
|
+
gsd_project = Path(".gsd") / "mcp.json"
|
|
427
|
+
gsd_user = Path.home() / ".gsd" / "mcp.json"
|
|
428
|
+
gsd_found = False
|
|
429
|
+
for gsd_path, label in [(gsd_project, "installed"), (gsd_user, "installed (user)")]:
|
|
430
|
+
if gsd_path.exists():
|
|
431
|
+
try:
|
|
432
|
+
data = json.loads(gsd_path.read_text())
|
|
433
|
+
if "attocode-code-intel" in data.get("servers", {}):
|
|
434
|
+
print(f" GSD: {label}")
|
|
435
|
+
gsd_found = True
|
|
436
|
+
break
|
|
437
|
+
except Exception:
|
|
438
|
+
print(" GSD: unable to check")
|
|
439
|
+
gsd_found = True
|
|
440
|
+
break
|
|
441
|
+
if not gsd_found:
|
|
442
|
+
print(" GSD: not installed")
|
|
443
|
+
|
|
444
|
+
# Check Codex (project-level)
|
|
445
|
+
codex_cfg = Path(".codex/config.toml")
|
|
446
|
+
if codex_cfg.exists():
|
|
447
|
+
try:
|
|
448
|
+
import tomllib
|
|
449
|
+
data = tomllib.loads(codex_cfg.read_text())
|
|
450
|
+
if "attocode-code-intel" in data.get("mcp_servers", {}):
|
|
451
|
+
print(" Codex: installed")
|
|
452
|
+
else:
|
|
453
|
+
print(" Codex: not installed")
|
|
454
|
+
except Exception:
|
|
455
|
+
print(" Codex: unable to check")
|
|
456
|
+
else:
|
|
457
|
+
# Also check user-level
|
|
458
|
+
codex_user_cfg = Path.home() / ".codex" / "config.toml"
|
|
459
|
+
if codex_user_cfg.exists():
|
|
460
|
+
try:
|
|
461
|
+
import tomllib
|
|
462
|
+
data = tomllib.loads(codex_user_cfg.read_text())
|
|
463
|
+
if "attocode-code-intel" in data.get("mcp_servers", {}):
|
|
464
|
+
print(" Codex: installed (user)")
|
|
465
|
+
else:
|
|
466
|
+
print(" Codex: not installed")
|
|
467
|
+
except Exception:
|
|
468
|
+
print(" Codex: unable to check")
|
|
469
|
+
else:
|
|
470
|
+
print(" Codex: not installed")
|
|
471
|
+
|
|
472
|
+
# Check Claude Desktop
|
|
473
|
+
claude_desktop_dir = _get_user_config_dir("claude-desktop")
|
|
474
|
+
if claude_desktop_dir is not None:
|
|
475
|
+
cfg = claude_desktop_dir / "claude_desktop_config.json"
|
|
476
|
+
if cfg.exists():
|
|
477
|
+
try:
|
|
478
|
+
data = json.loads(cfg.read_text())
|
|
479
|
+
if "attocode-code-intel" in data.get("mcpServers", {}):
|
|
480
|
+
print(" Claude Desktop: installed")
|
|
481
|
+
else:
|
|
482
|
+
print(" Claude Desktop: not installed")
|
|
483
|
+
except Exception:
|
|
484
|
+
print(" Claude Desktop: unable to check")
|
|
485
|
+
else:
|
|
486
|
+
print(" Claude Desktop: not installed")
|
|
487
|
+
else:
|
|
488
|
+
print(" Claude Desktop: not supported on this platform")
|
|
489
|
+
|
|
490
|
+
# Check Cline
|
|
491
|
+
cline_dir = _get_user_config_dir("cline")
|
|
492
|
+
if cline_dir is not None:
|
|
493
|
+
cfg = cline_dir / "cline_mcp_settings.json"
|
|
494
|
+
if cfg.exists():
|
|
495
|
+
try:
|
|
496
|
+
data = json.loads(cfg.read_text())
|
|
497
|
+
if "attocode-code-intel" in data.get("mcpServers", {}):
|
|
498
|
+
print(" Cline: installed")
|
|
499
|
+
else:
|
|
500
|
+
print(" Cline: not installed")
|
|
501
|
+
except Exception:
|
|
502
|
+
print(" Cline: unable to check")
|
|
503
|
+
else:
|
|
504
|
+
print(" Cline: not installed")
|
|
505
|
+
else:
|
|
506
|
+
print(" Cline: not supported on this platform")
|
|
507
|
+
|
|
508
|
+
# Check Zed (project-level, then user-level)
|
|
509
|
+
zed_local = Path(".zed/settings.json")
|
|
510
|
+
zed_user = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "zed" / "settings.json"
|
|
511
|
+
zed_found = False
|
|
512
|
+
for zed_path, label in [(zed_local, "installed"), (zed_user, "installed (user)")]:
|
|
513
|
+
if zed_path.exists():
|
|
514
|
+
try:
|
|
515
|
+
data = json.loads(zed_path.read_text())
|
|
516
|
+
if "attocode-code-intel" in data.get("context_servers", {}):
|
|
517
|
+
print(f" Zed: {label}")
|
|
518
|
+
zed_found = True
|
|
519
|
+
break
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
if not zed_found:
|
|
523
|
+
print(" Zed: not installed")
|
|
524
|
+
|
|
525
|
+
# Check if entry point is available
|
|
526
|
+
from attocode_intel.installer import _find_command
|
|
527
|
+
|
|
528
|
+
print()
|
|
529
|
+
resolved = _find_command(".")
|
|
530
|
+
if resolved == "attocode-code-intel":
|
|
531
|
+
print(" Entry point: attocode-code-intel (on PATH)")
|
|
532
|
+
else:
|
|
533
|
+
print(f" Entry point: {resolved}")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _cmd_probe_install(args: list[str]) -> None:
|
|
537
|
+
"""Run a runtime MCP probe for a file-based installed target."""
|
|
538
|
+
from attocode_intel.installer import ALL_TARGETS_STR
|
|
539
|
+
from attocode_intel.probe import probe_install
|
|
540
|
+
|
|
541
|
+
target, project_dir, scope, _hooks = _parse_opts(args)
|
|
542
|
+
if not target:
|
|
543
|
+
print(f"Error: specify a target ({ALL_TARGETS_STR})", file=sys.stderr)
|
|
544
|
+
sys.exit(1)
|
|
545
|
+
|
|
546
|
+
exit_code = probe_install(
|
|
547
|
+
target,
|
|
548
|
+
project_dir=project_dir,
|
|
549
|
+
scope=scope,
|
|
550
|
+
force_project_probe=_args_include_project_flag(args),
|
|
551
|
+
)
|
|
552
|
+
if exit_code:
|
|
553
|
+
sys.exit(exit_code)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _cmd_bundle(args: list[str]) -> None:
|
|
557
|
+
"""Export or inspect local code-intel bundles."""
|
|
558
|
+
if not args or args[0] in {"-h", "--help", "help"}:
|
|
559
|
+
print(
|
|
560
|
+
"Usage:\n"
|
|
561
|
+
" attocode code-intel bundle export [--project <path>] [--output <bundle.tar.gz>]\n"
|
|
562
|
+
" attocode code-intel bundle inspect <bundle.tar.gz>\n"
|
|
563
|
+
)
|
|
564
|
+
return
|
|
565
|
+
|
|
566
|
+
subcmd = args[0]
|
|
567
|
+
if subcmd == "export":
|
|
568
|
+
from attocode_intel.bundle import export_bundle
|
|
569
|
+
|
|
570
|
+
_, project_dir, _, _ = _parse_opts(args[1:])
|
|
571
|
+
output_path = ""
|
|
572
|
+
tail = args[1:]
|
|
573
|
+
i = 0
|
|
574
|
+
while i < len(tail):
|
|
575
|
+
arg = tail[i]
|
|
576
|
+
if arg == "--output" and i + 1 < len(tail):
|
|
577
|
+
output_path = tail[i + 1]
|
|
578
|
+
i += 2
|
|
579
|
+
elif arg.startswith("--output="):
|
|
580
|
+
output_path = arg.split("=", 1)[1]
|
|
581
|
+
i += 1
|
|
582
|
+
else:
|
|
583
|
+
i += 1
|
|
584
|
+
|
|
585
|
+
if not output_path:
|
|
586
|
+
bundle_name = f"attocode-bundle-{os.path.basename(os.path.abspath(project_dir))}.tar.gz"
|
|
587
|
+
output_path = os.path.join(os.getcwd(), bundle_name)
|
|
588
|
+
|
|
589
|
+
destination = export_bundle(project_dir, output_path)
|
|
590
|
+
print(f"Bundle exported to {destination}")
|
|
591
|
+
return
|
|
592
|
+
|
|
593
|
+
if subcmd == "inspect":
|
|
594
|
+
import json as json_mod
|
|
595
|
+
import tarfile
|
|
596
|
+
|
|
597
|
+
from attocode_intel.bundle import inspect_bundle
|
|
598
|
+
|
|
599
|
+
bundle_path = ""
|
|
600
|
+
for arg in args[1:]:
|
|
601
|
+
if not arg.startswith("-"):
|
|
602
|
+
bundle_path = arg
|
|
603
|
+
break
|
|
604
|
+
if not bundle_path:
|
|
605
|
+
print("Error: specify a bundle path.", file=sys.stderr)
|
|
606
|
+
sys.exit(1)
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
metadata = inspect_bundle(bundle_path)
|
|
610
|
+
except (FileNotFoundError, OSError, tarfile.TarError, KeyError, json_mod.JSONDecodeError) as exc:
|
|
611
|
+
print(f"Error: could not inspect bundle: {exc}", file=sys.stderr)
|
|
612
|
+
sys.exit(1)
|
|
613
|
+
print(f"Bundle: {os.path.abspath(bundle_path)}")
|
|
614
|
+
print(f" Schema version: {metadata.get('schema_version')}")
|
|
615
|
+
print(f" Created at: {metadata.get('created_at')}")
|
|
616
|
+
print(f" Project: {metadata.get('project_name')}")
|
|
617
|
+
print(f" Attocode version: {metadata.get('attocode_version')}")
|
|
618
|
+
print(" Artifacts:")
|
|
619
|
+
for artifact in metadata.get("artifacts", []):
|
|
620
|
+
status = "present" if artifact.get("present") else "missing"
|
|
621
|
+
print(
|
|
622
|
+
" "
|
|
623
|
+
f"{artifact.get('path')}: {status}, "
|
|
624
|
+
f"size={artifact.get('size_bytes')}, sha256={artifact.get('sha256')}"
|
|
625
|
+
)
|
|
626
|
+
return
|
|
627
|
+
|
|
628
|
+
print(f"Error: unknown bundle command '{subcmd}'", file=sys.stderr)
|
|
629
|
+
sys.exit(1)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _cmd_notify(args: list[str]) -> None:
|
|
633
|
+
"""Notify the server about changed files via the notification queue.
|
|
634
|
+
|
|
635
|
+
Writes file paths to .attocode/cache/file_changes for the server to pick up.
|
|
636
|
+
"""
|
|
637
|
+
import json as json_mod
|
|
638
|
+
from pathlib import Path
|
|
639
|
+
|
|
640
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
641
|
+
project_dir = os.path.abspath(project_dir)
|
|
642
|
+
|
|
643
|
+
files: list[str] = []
|
|
644
|
+
|
|
645
|
+
# Parse --file flags
|
|
646
|
+
i = 0
|
|
647
|
+
while i < len(args):
|
|
648
|
+
if args[i] == "--file" and i + 1 < len(args):
|
|
649
|
+
files.append(args[i + 1])
|
|
650
|
+
i += 2
|
|
651
|
+
elif args[i].startswith("--file="):
|
|
652
|
+
files.append(args[i].split("=", 1)[1])
|
|
653
|
+
i += 1
|
|
654
|
+
elif args[i] == "--stdin":
|
|
655
|
+
# Read from stdin: support both raw lines and JSON with tool_input
|
|
656
|
+
for line in sys.stdin:
|
|
657
|
+
line = line.strip()
|
|
658
|
+
if not line:
|
|
659
|
+
continue
|
|
660
|
+
if line.startswith("{"):
|
|
661
|
+
try:
|
|
662
|
+
data = json_mod.loads(line)
|
|
663
|
+
fp = (
|
|
664
|
+
data.get("tool_input", {}).get("file_path")
|
|
665
|
+
or data.get("file_path")
|
|
666
|
+
)
|
|
667
|
+
if fp:
|
|
668
|
+
files.append(fp)
|
|
669
|
+
except json_mod.JSONDecodeError:
|
|
670
|
+
pass
|
|
671
|
+
else:
|
|
672
|
+
files.append(line)
|
|
673
|
+
i += 1
|
|
674
|
+
else:
|
|
675
|
+
i += 1
|
|
676
|
+
|
|
677
|
+
if not files:
|
|
678
|
+
print("No files specified. Use --file <path> or --stdin.", file=sys.stderr)
|
|
679
|
+
sys.exit(1)
|
|
680
|
+
|
|
681
|
+
# Resolve to relative paths and write to queue
|
|
682
|
+
queue_path = Path(project_dir) / ".attocode" / "cache" / "file_changes"
|
|
683
|
+
queue_path.parent.mkdir(parents=True, exist_ok=True)
|
|
684
|
+
|
|
685
|
+
rel_paths: list[str] = []
|
|
686
|
+
for f in files:
|
|
687
|
+
p = Path(f)
|
|
688
|
+
if p.is_absolute():
|
|
689
|
+
try:
|
|
690
|
+
rel = os.path.relpath(str(p), project_dir)
|
|
691
|
+
except ValueError:
|
|
692
|
+
rel = str(p)
|
|
693
|
+
else:
|
|
694
|
+
rel = str(p)
|
|
695
|
+
rel_paths.append(rel)
|
|
696
|
+
|
|
697
|
+
# Check if remote server is configured — POST instead of local queue
|
|
698
|
+
from attocode_intel.config import load_remote_config
|
|
699
|
+
|
|
700
|
+
remote_cfg = load_remote_config(project_dir)
|
|
701
|
+
if remote_cfg.is_configured:
|
|
702
|
+
_notify_remote(remote_cfg, rel_paths, project_dir)
|
|
703
|
+
return
|
|
704
|
+
|
|
705
|
+
# Fallback: local queue file for MCP server to pick up
|
|
706
|
+
try:
|
|
707
|
+
import fcntl
|
|
708
|
+
with queue_path.open("a", encoding="utf-8") as fh:
|
|
709
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
710
|
+
for rp in rel_paths:
|
|
711
|
+
fh.write(rp + "\n")
|
|
712
|
+
except ImportError:
|
|
713
|
+
# Windows: fall back to non-locked append
|
|
714
|
+
with queue_path.open("a", encoding="utf-8") as fh:
|
|
715
|
+
for rp in rel_paths:
|
|
716
|
+
fh.write(rp + "\n")
|
|
717
|
+
|
|
718
|
+
print(f"Queued {len(rel_paths)} file(s) for index update.")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _notify_remote(remote_cfg, rel_paths: list[str], project_dir: str) -> None:
|
|
722
|
+
"""POST file changes to the remote server, including file contents."""
|
|
723
|
+
import base64
|
|
724
|
+
import subprocess
|
|
725
|
+
|
|
726
|
+
try:
|
|
727
|
+
import httpx
|
|
728
|
+
except ImportError:
|
|
729
|
+
print(
|
|
730
|
+
"Error: httpx not installed. Install with: pip install httpx",
|
|
731
|
+
file=sys.stderr,
|
|
732
|
+
)
|
|
733
|
+
sys.exit(1)
|
|
734
|
+
|
|
735
|
+
# Auto-detect branch from git
|
|
736
|
+
branch = "main"
|
|
737
|
+
try:
|
|
738
|
+
result = subprocess.run(
|
|
739
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
740
|
+
cwd=project_dir,
|
|
741
|
+
capture_output=True,
|
|
742
|
+
text=True,
|
|
743
|
+
timeout=5,
|
|
744
|
+
)
|
|
745
|
+
if result.returncode == 0:
|
|
746
|
+
branch = result.stdout.strip()
|
|
747
|
+
except Exception:
|
|
748
|
+
pass
|
|
749
|
+
|
|
750
|
+
# Read file contents and base64-encode them
|
|
751
|
+
files: dict[str, str] = {}
|
|
752
|
+
for rel_path in rel_paths:
|
|
753
|
+
full = os.path.join(project_dir, rel_path)
|
|
754
|
+
if os.path.isfile(full):
|
|
755
|
+
try:
|
|
756
|
+
with open(full, "rb") as f:
|
|
757
|
+
files[rel_path] = base64.b64encode(f.read()).decode("ascii")
|
|
758
|
+
except (OSError, PermissionError):
|
|
759
|
+
pass # deleted or unreadable — omit, server treats as deleted
|
|
760
|
+
|
|
761
|
+
server = remote_cfg.server.rstrip("/")
|
|
762
|
+
try:
|
|
763
|
+
resp = httpx.post(
|
|
764
|
+
f"{server}/api/v1/notify/file-changed",
|
|
765
|
+
json={
|
|
766
|
+
"paths": rel_paths,
|
|
767
|
+
"files": files,
|
|
768
|
+
"project": remote_cfg.repo_id,
|
|
769
|
+
"branch": branch,
|
|
770
|
+
},
|
|
771
|
+
headers={"Authorization": f"Bearer {remote_cfg.token}"},
|
|
772
|
+
timeout=30,
|
|
773
|
+
)
|
|
774
|
+
if resp.status_code in (200, 202):
|
|
775
|
+
data = resp.json()
|
|
776
|
+
print(f"Notified remote server: {data.get('accepted', len(rel_paths))} file(s) accepted.")
|
|
777
|
+
else:
|
|
778
|
+
print(
|
|
779
|
+
f"Remote server returned {resp.status_code}: {resp.text}",
|
|
780
|
+
file=sys.stderr,
|
|
781
|
+
)
|
|
782
|
+
sys.exit(1)
|
|
783
|
+
except httpx.ConnectError:
|
|
784
|
+
print(f"Error: cannot connect to {server}", file=sys.stderr)
|
|
785
|
+
sys.exit(1)
|
|
786
|
+
except httpx.TimeoutException:
|
|
787
|
+
print(f"Error: request to {server} timed out", file=sys.stderr)
|
|
788
|
+
sys.exit(1)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _prompt(label: str, *, default: str = "", secret: bool = False) -> str:
|
|
792
|
+
"""Prompt user for input. Uses getpass for secrets."""
|
|
793
|
+
if secret:
|
|
794
|
+
import getpass
|
|
795
|
+
|
|
796
|
+
suffix = f" [{default}]" if default else ""
|
|
797
|
+
val = getpass.getpass(f"{label}{suffix}: ")
|
|
798
|
+
return val or default
|
|
799
|
+
suffix = f" [{default}]" if default else ""
|
|
800
|
+
val = input(f"{label}{suffix}: ").strip()
|
|
801
|
+
return val or default
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _bootstrap_user_and_project(
|
|
805
|
+
api_base: str,
|
|
806
|
+
project_dir: str,
|
|
807
|
+
state_path: str,
|
|
808
|
+
*,
|
|
809
|
+
email: str = "",
|
|
810
|
+
password: str = "",
|
|
811
|
+
org_slug: str = "",
|
|
812
|
+
repo_name: str = "",
|
|
813
|
+
interactive: bool = True,
|
|
814
|
+
) -> dict:
|
|
815
|
+
"""Register/login, select org, add repo. Returns state dict.
|
|
816
|
+
|
|
817
|
+
Shared logic used by both ``connect`` (user-provided credentials) and
|
|
818
|
+
``setup`` (hardcoded dev credentials).
|
|
819
|
+
"""
|
|
820
|
+
import json
|
|
821
|
+
from pathlib import Path
|
|
822
|
+
|
|
823
|
+
try:
|
|
824
|
+
import httpx
|
|
825
|
+
except ImportError:
|
|
826
|
+
print("Error: httpx not installed. Install with: uv sync --extra service", file=sys.stderr)
|
|
827
|
+
sys.exit(1)
|
|
828
|
+
|
|
829
|
+
# Load existing state for idempotency
|
|
830
|
+
state: dict = {}
|
|
831
|
+
if os.path.isfile(state_path):
|
|
832
|
+
try:
|
|
833
|
+
state = json.loads(Path(state_path).read_text(encoding="utf-8"))
|
|
834
|
+
except Exception:
|
|
835
|
+
pass
|
|
836
|
+
|
|
837
|
+
# --- Auth: register or login ---
|
|
838
|
+
if not state.get("token"):
|
|
839
|
+
if not email:
|
|
840
|
+
if not interactive:
|
|
841
|
+
print("Error: --email required in non-interactive mode", file=sys.stderr)
|
|
842
|
+
sys.exit(1)
|
|
843
|
+
email = _prompt("Email")
|
|
844
|
+
if not password:
|
|
845
|
+
if not interactive:
|
|
846
|
+
print("Error: --password required in non-interactive mode", file=sys.stderr)
|
|
847
|
+
sys.exit(1)
|
|
848
|
+
password = _prompt("Password", secret=True)
|
|
849
|
+
if not email or not password:
|
|
850
|
+
print("Error: email and password are required", file=sys.stderr)
|
|
851
|
+
sys.exit(1)
|
|
852
|
+
|
|
853
|
+
# Try register first, fall back to login
|
|
854
|
+
resp = httpx.post(
|
|
855
|
+
f"{api_base}/api/v1/auth/register",
|
|
856
|
+
json={"email": email, "password": password, "name": email.split("@")[0]},
|
|
857
|
+
timeout=10,
|
|
858
|
+
)
|
|
859
|
+
if resp.status_code in (200, 201):
|
|
860
|
+
data = resp.json()
|
|
861
|
+
state["token"] = data["access_token"]
|
|
862
|
+
state["user_id"] = data.get("user_id", data.get("id", ""))
|
|
863
|
+
print("Registered new account.", file=sys.stderr)
|
|
864
|
+
elif resp.status_code == 409:
|
|
865
|
+
resp2 = httpx.post(
|
|
866
|
+
f"{api_base}/api/v1/auth/login",
|
|
867
|
+
json={"email": email, "password": password},
|
|
868
|
+
timeout=10,
|
|
869
|
+
)
|
|
870
|
+
if resp2.status_code == 200:
|
|
871
|
+
data = resp2.json()
|
|
872
|
+
state["token"] = data["access_token"]
|
|
873
|
+
state["user_id"] = data.get("user_id", data.get("id", ""))
|
|
874
|
+
print("Logged in.", file=sys.stderr)
|
|
875
|
+
else:
|
|
876
|
+
print(f"Error logging in: {resp2.status_code} {resp2.text}", file=sys.stderr)
|
|
877
|
+
sys.exit(1)
|
|
878
|
+
else:
|
|
879
|
+
print(f"Error registering: {resp.status_code} {resp.text}", file=sys.stderr)
|
|
880
|
+
sys.exit(1)
|
|
881
|
+
|
|
882
|
+
headers = {"Authorization": f"Bearer {state['token']}"}
|
|
883
|
+
|
|
884
|
+
# --- Org selection ---
|
|
885
|
+
if not state.get("org_id"):
|
|
886
|
+
resp = httpx.get(f"{api_base}/api/v1/orgs", headers=headers, timeout=10)
|
|
887
|
+
if resp.status_code != 200:
|
|
888
|
+
print(f"Error listing orgs: {resp.status_code}", file=sys.stderr)
|
|
889
|
+
sys.exit(1)
|
|
890
|
+
|
|
891
|
+
orgs_raw = resp.json()
|
|
892
|
+
orgs = orgs_raw if isinstance(orgs_raw, list) else (orgs_raw.get("organizations") or orgs_raw.get("items", []))
|
|
893
|
+
|
|
894
|
+
# Filter by slug if provided
|
|
895
|
+
if org_slug:
|
|
896
|
+
match = [o for o in orgs if o.get("slug") == org_slug or o.get("id") == org_slug]
|
|
897
|
+
if match:
|
|
898
|
+
state["org_id"] = match[0]["id"]
|
|
899
|
+
print(f"Using org: {match[0].get('name', org_slug)}", file=sys.stderr)
|
|
900
|
+
else:
|
|
901
|
+
# Create it
|
|
902
|
+
resp2 = httpx.post(
|
|
903
|
+
f"{api_base}/api/v1/orgs",
|
|
904
|
+
json={"name": org_slug, "slug": org_slug},
|
|
905
|
+
headers=headers,
|
|
906
|
+
timeout=10,
|
|
907
|
+
)
|
|
908
|
+
if resp2.status_code in (200, 201):
|
|
909
|
+
state["org_id"] = resp2.json()["id"]
|
|
910
|
+
print(f"Created org: {org_slug}", file=sys.stderr)
|
|
911
|
+
else:
|
|
912
|
+
print(f"Error creating org: {resp2.status_code} {resp2.text}", file=sys.stderr)
|
|
913
|
+
sys.exit(1)
|
|
914
|
+
elif len(orgs) == 1:
|
|
915
|
+
state["org_id"] = orgs[0]["id"]
|
|
916
|
+
print(f"Using org: {orgs[0].get('name', orgs[0]['id'])}", file=sys.stderr)
|
|
917
|
+
elif len(orgs) > 1:
|
|
918
|
+
if not interactive:
|
|
919
|
+
print("Error: multiple orgs found, specify --org", file=sys.stderr)
|
|
920
|
+
sys.exit(1)
|
|
921
|
+
print("\nAvailable organizations:", file=sys.stderr)
|
|
922
|
+
for idx, org in enumerate(orgs, 1):
|
|
923
|
+
print(f" {idx}. {org.get('name', '?')} ({org.get('slug', '')})", file=sys.stderr)
|
|
924
|
+
choice = _prompt("Select org number", default="1")
|
|
925
|
+
try:
|
|
926
|
+
state["org_id"] = orgs[int(choice) - 1]["id"]
|
|
927
|
+
except (ValueError, IndexError):
|
|
928
|
+
print("Invalid selection.", file=sys.stderr)
|
|
929
|
+
sys.exit(1)
|
|
930
|
+
else:
|
|
931
|
+
# No orgs — create one
|
|
932
|
+
if interactive:
|
|
933
|
+
slug = _prompt("No orgs found. Create one — slug", default="my-org")
|
|
934
|
+
else:
|
|
935
|
+
slug = org_slug or "default"
|
|
936
|
+
resp2 = httpx.post(
|
|
937
|
+
f"{api_base}/api/v1/orgs",
|
|
938
|
+
json={"name": slug, "slug": slug},
|
|
939
|
+
headers=headers,
|
|
940
|
+
timeout=10,
|
|
941
|
+
)
|
|
942
|
+
if resp2.status_code in (200, 201):
|
|
943
|
+
state["org_id"] = resp2.json()["id"]
|
|
944
|
+
print(f"Created org: {slug}", file=sys.stderr)
|
|
945
|
+
elif resp2.status_code == 409:
|
|
946
|
+
# Org exists but user isn't a member — look it up by slug
|
|
947
|
+
resp3 = httpx.get(
|
|
948
|
+
f"{api_base}/api/v1/orgs",
|
|
949
|
+
headers=headers,
|
|
950
|
+
params={"slug": slug},
|
|
951
|
+
timeout=10,
|
|
952
|
+
)
|
|
953
|
+
found = False
|
|
954
|
+
if resp3.status_code == 200:
|
|
955
|
+
orgs3 = resp3.json() if isinstance(resp3.json(), list) else resp3.json().get("items", [])
|
|
956
|
+
for o in orgs3:
|
|
957
|
+
if o.get("slug") == slug:
|
|
958
|
+
state["org_id"] = o["id"]
|
|
959
|
+
print(f"Joined existing org: {slug}", file=sys.stderr)
|
|
960
|
+
found = True
|
|
961
|
+
break
|
|
962
|
+
if not found:
|
|
963
|
+
# Last resort: parse the 409 response for org id, or just re-list all
|
|
964
|
+
resp4 = httpx.get(f"{api_base}/api/v1/orgs", headers=headers, timeout=10)
|
|
965
|
+
if resp4.status_code == 200:
|
|
966
|
+
orgs4 = resp4.json() if isinstance(resp4.json(), list) else resp4.json().get("items", [])
|
|
967
|
+
for o in orgs4:
|
|
968
|
+
if o.get("slug") == slug or o.get("name") == slug:
|
|
969
|
+
state["org_id"] = o["id"]
|
|
970
|
+
print(f"Using existing org: {slug}", file=sys.stderr)
|
|
971
|
+
found = True
|
|
972
|
+
break
|
|
973
|
+
if not found:
|
|
974
|
+
print(f"Error: org '{slug}' exists but could not resolve its ID.", file=sys.stderr)
|
|
975
|
+
sys.exit(1)
|
|
976
|
+
else:
|
|
977
|
+
print(f"Error creating org: {resp2.status_code} {resp2.text}", file=sys.stderr)
|
|
978
|
+
sys.exit(1)
|
|
979
|
+
|
|
980
|
+
# --- Repo selection ---
|
|
981
|
+
if not state.get("repo_id"):
|
|
982
|
+
resp = httpx.get(
|
|
983
|
+
f"{api_base}/api/v1/orgs/{state['org_id']}/repos",
|
|
984
|
+
headers=headers,
|
|
985
|
+
timeout=10,
|
|
986
|
+
)
|
|
987
|
+
if resp.status_code != 200:
|
|
988
|
+
print(f"Error listing repos: {resp.status_code}", file=sys.stderr)
|
|
989
|
+
sys.exit(1)
|
|
990
|
+
|
|
991
|
+
repos_raw = resp.json()
|
|
992
|
+
repos = repos_raw if isinstance(repos_raw, list) else (repos_raw.get("repositories") or repos_raw.get("items", []))
|
|
993
|
+
|
|
994
|
+
# Detect local git remote origin for matching and repo creation
|
|
995
|
+
local_clone_url = None
|
|
996
|
+
try:
|
|
997
|
+
import subprocess
|
|
998
|
+
origin = subprocess.run(
|
|
999
|
+
["git", "remote", "get-url", "origin"],
|
|
1000
|
+
cwd=project_dir, capture_output=True, text=True, timeout=5,
|
|
1001
|
+
)
|
|
1002
|
+
if origin.returncode == 0 and origin.stdout.strip():
|
|
1003
|
+
local_clone_url = origin.stdout.strip()
|
|
1004
|
+
except Exception:
|
|
1005
|
+
pass
|
|
1006
|
+
|
|
1007
|
+
# Try auto-match by local_path
|
|
1008
|
+
for repo in repos:
|
|
1009
|
+
if repo.get("local_path") == project_dir:
|
|
1010
|
+
state["repo_id"] = repo["id"]
|
|
1011
|
+
print(f"Matched existing repo: {repo.get('name', repo['id'])}", file=sys.stderr)
|
|
1012
|
+
break
|
|
1013
|
+
|
|
1014
|
+
# Try auto-match by clone_url
|
|
1015
|
+
if not state.get("repo_id") and local_clone_url:
|
|
1016
|
+
for repo in repos:
|
|
1017
|
+
repo_url = repo.get("clone_url", "")
|
|
1018
|
+
if repo_url and _urls_match(local_clone_url, repo_url):
|
|
1019
|
+
state["repo_id"] = repo["id"]
|
|
1020
|
+
print(f"Matched existing repo by remote URL: {repo.get('name', repo['id'])}", file=sys.stderr)
|
|
1021
|
+
break
|
|
1022
|
+
|
|
1023
|
+
if not state.get("repo_id"):
|
|
1024
|
+
# Add current project as a new repo
|
|
1025
|
+
if not repo_name:
|
|
1026
|
+
repo_name = os.path.basename(project_dir)
|
|
1027
|
+
default_branch = _detect_default_branch(project_dir)
|
|
1028
|
+
|
|
1029
|
+
clone_url = local_clone_url
|
|
1030
|
+
|
|
1031
|
+
repo_payload: dict = {
|
|
1032
|
+
"name": repo_name,
|
|
1033
|
+
"local_path": project_dir,
|
|
1034
|
+
"default_branch": default_branch,
|
|
1035
|
+
}
|
|
1036
|
+
if clone_url:
|
|
1037
|
+
repo_payload["clone_url"] = clone_url
|
|
1038
|
+
|
|
1039
|
+
resp2 = httpx.post(
|
|
1040
|
+
f"{api_base}/api/v1/orgs/{state['org_id']}/repos",
|
|
1041
|
+
json=repo_payload,
|
|
1042
|
+
headers=headers,
|
|
1043
|
+
timeout=10,
|
|
1044
|
+
)
|
|
1045
|
+
if resp2.status_code in (200, 201):
|
|
1046
|
+
state["repo_id"] = resp2.json()["id"]
|
|
1047
|
+
print(f"Added repo: {repo_name} ({state['repo_id']})", file=sys.stderr)
|
|
1048
|
+
elif resp2.status_code == 409:
|
|
1049
|
+
# Already exists — search by name
|
|
1050
|
+
for repo in repos:
|
|
1051
|
+
if repo.get("name") == repo_name:
|
|
1052
|
+
state["repo_id"] = repo["id"]
|
|
1053
|
+
break
|
|
1054
|
+
if state.get("repo_id"):
|
|
1055
|
+
print(f"Using existing repo: {state['repo_id']}", file=sys.stderr)
|
|
1056
|
+
else:
|
|
1057
|
+
print("Error: repo conflict but could not find existing.", file=sys.stderr)
|
|
1058
|
+
sys.exit(1)
|
|
1059
|
+
else:
|
|
1060
|
+
print(f"Error adding repo: {resp2.status_code} {resp2.text}", file=sys.stderr)
|
|
1061
|
+
sys.exit(1)
|
|
1062
|
+
|
|
1063
|
+
# --- Save state ---
|
|
1064
|
+
Path(state_path).parent.mkdir(parents=True, exist_ok=True)
|
|
1065
|
+
Path(state_path).write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
1066
|
+
|
|
1067
|
+
return state
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _urls_match(url_a: str, url_b: str) -> bool:
|
|
1071
|
+
"""Compare git URLs, normalizing protocol and .git suffix."""
|
|
1072
|
+
|
|
1073
|
+
def _normalize(u: str) -> str:
|
|
1074
|
+
u = u.strip().rstrip("/")
|
|
1075
|
+
if u.endswith(".git"):
|
|
1076
|
+
u = u[:-4]
|
|
1077
|
+
if u.startswith("git@"):
|
|
1078
|
+
u = u[4:].replace(":", "/", 1)
|
|
1079
|
+
for prefix in ("https://", "http://", "ssh://"):
|
|
1080
|
+
if u.startswith(prefix):
|
|
1081
|
+
u = u[len(prefix):]
|
|
1082
|
+
break
|
|
1083
|
+
return u.lower()
|
|
1084
|
+
|
|
1085
|
+
return _normalize(url_a) == _normalize(url_b)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
_SYNC_CODE_EXTENSIONS = {
|
|
1089
|
+
".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".rs", ".java",
|
|
1090
|
+
".rb", ".c", ".cpp", ".h", ".hpp", ".cs", ".swift", ".kt",
|
|
1091
|
+
".toml", ".yaml", ".yml", ".json", ".md", ".txt", ".cfg",
|
|
1092
|
+
".sh", ".bash", ".zsh", ".fish", ".sql", ".graphql",
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _initial_sync(remote_cfg, project_dir: str, batch_size: int = 50) -> None:
|
|
1097
|
+
"""Push all git-tracked code files to the server after connect.
|
|
1098
|
+
|
|
1099
|
+
Uses ``git ls-files`` to enumerate tracked files, filters by extension,
|
|
1100
|
+
and POSTs them in batches via the notify endpoint (same path as watch/notify).
|
|
1101
|
+
"""
|
|
1102
|
+
import base64
|
|
1103
|
+
import subprocess
|
|
1104
|
+
|
|
1105
|
+
try:
|
|
1106
|
+
import httpx
|
|
1107
|
+
except ImportError:
|
|
1108
|
+
return
|
|
1109
|
+
|
|
1110
|
+
# Get git-tracked files
|
|
1111
|
+
try:
|
|
1112
|
+
result = subprocess.run(
|
|
1113
|
+
["git", "ls-files"],
|
|
1114
|
+
cwd=project_dir,
|
|
1115
|
+
capture_output=True, text=True, timeout=15,
|
|
1116
|
+
)
|
|
1117
|
+
if result.returncode != 0:
|
|
1118
|
+
print(" [skip] Could not list git files for initial sync.", file=sys.stderr)
|
|
1119
|
+
return
|
|
1120
|
+
except Exception:
|
|
1121
|
+
return
|
|
1122
|
+
|
|
1123
|
+
all_files = [f for f in result.stdout.strip().splitlines() if f]
|
|
1124
|
+
# Filter to code files
|
|
1125
|
+
code_files = [
|
|
1126
|
+
f for f in all_files
|
|
1127
|
+
if os.path.splitext(f)[1].lower() in _SYNC_CODE_EXTENSIONS
|
|
1128
|
+
]
|
|
1129
|
+
if not code_files:
|
|
1130
|
+
print(" [skip] No code files to sync.", file=sys.stderr)
|
|
1131
|
+
return
|
|
1132
|
+
|
|
1133
|
+
# Detect branch
|
|
1134
|
+
branch = "main"
|
|
1135
|
+
try:
|
|
1136
|
+
br = subprocess.run(
|
|
1137
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
1138
|
+
cwd=project_dir,
|
|
1139
|
+
capture_output=True, text=True, timeout=5,
|
|
1140
|
+
)
|
|
1141
|
+
if br.returncode == 0:
|
|
1142
|
+
branch = br.stdout.strip()
|
|
1143
|
+
except Exception:
|
|
1144
|
+
pass
|
|
1145
|
+
|
|
1146
|
+
server = remote_cfg.server.rstrip("/")
|
|
1147
|
+
headers = {"Authorization": f"Bearer {remote_cfg.token}"}
|
|
1148
|
+
client = httpx.Client(timeout=60)
|
|
1149
|
+
|
|
1150
|
+
total = len(code_files)
|
|
1151
|
+
synced = 0
|
|
1152
|
+
bulk_batch_size = 500 # Bulk sync supports up to 500 files
|
|
1153
|
+
print(f"\n Initial sync: pushing {total} tracked files...", file=sys.stderr)
|
|
1154
|
+
|
|
1155
|
+
# Try bulk-sync endpoint first (faster, no debounce)
|
|
1156
|
+
use_bulk = True
|
|
1157
|
+
try:
|
|
1158
|
+
for i in range(0, total, bulk_batch_size):
|
|
1159
|
+
batch = code_files[i : i + bulk_batch_size]
|
|
1160
|
+
files: dict[str, str] = {}
|
|
1161
|
+
for rel_path in batch:
|
|
1162
|
+
full = os.path.join(project_dir, rel_path)
|
|
1163
|
+
if os.path.isfile(full):
|
|
1164
|
+
try:
|
|
1165
|
+
with open(full, "rb") as f:
|
|
1166
|
+
files[rel_path] = base64.b64encode(f.read()).decode("ascii")
|
|
1167
|
+
except (OSError, PermissionError):
|
|
1168
|
+
pass
|
|
1169
|
+
|
|
1170
|
+
if not files:
|
|
1171
|
+
continue
|
|
1172
|
+
|
|
1173
|
+
if use_bulk:
|
|
1174
|
+
try:
|
|
1175
|
+
resp = client.post(
|
|
1176
|
+
f"{server}/api/v1/notify/bulk-sync",
|
|
1177
|
+
json={
|
|
1178
|
+
"files": files,
|
|
1179
|
+
"project": remote_cfg.repo_id,
|
|
1180
|
+
"branch": branch,
|
|
1181
|
+
},
|
|
1182
|
+
headers=headers,
|
|
1183
|
+
)
|
|
1184
|
+
if resp.status_code == 200:
|
|
1185
|
+
synced += len(files)
|
|
1186
|
+
print(f" [{synced}/{total}] synced (bulk)", file=sys.stderr)
|
|
1187
|
+
continue
|
|
1188
|
+
elif resp.status_code == 404:
|
|
1189
|
+
# Server doesn't support bulk-sync, fall back
|
|
1190
|
+
use_bulk = False
|
|
1191
|
+
else:
|
|
1192
|
+
print(f" Bulk sync error: {resp.status_code}", file=sys.stderr)
|
|
1193
|
+
use_bulk = False
|
|
1194
|
+
except Exception:
|
|
1195
|
+
use_bulk = False
|
|
1196
|
+
|
|
1197
|
+
# Fallback: regular notify in smaller batches
|
|
1198
|
+
for j in range(0, len(files), batch_size):
|
|
1199
|
+
sub_files = dict(list(files.items())[j : j + batch_size])
|
|
1200
|
+
try:
|
|
1201
|
+
resp = client.post(
|
|
1202
|
+
f"{server}/api/v1/notify/file-changed",
|
|
1203
|
+
json={
|
|
1204
|
+
"paths": list(sub_files.keys()),
|
|
1205
|
+
"files": sub_files,
|
|
1206
|
+
"project": remote_cfg.repo_id,
|
|
1207
|
+
"branch": branch,
|
|
1208
|
+
},
|
|
1209
|
+
headers=headers,
|
|
1210
|
+
)
|
|
1211
|
+
if resp.status_code in (200, 202):
|
|
1212
|
+
synced += len(sub_files)
|
|
1213
|
+
print(f" [{synced}/{total}] synced", file=sys.stderr)
|
|
1214
|
+
else:
|
|
1215
|
+
print(f" Batch error: server returned {resp.status_code}", file=sys.stderr)
|
|
1216
|
+
except Exception as e:
|
|
1217
|
+
print(f" Batch error: {e}", file=sys.stderr)
|
|
1218
|
+
finally:
|
|
1219
|
+
# Flush debouncer if we used regular notify
|
|
1220
|
+
if synced > 0 and not use_bulk:
|
|
1221
|
+
try:
|
|
1222
|
+
client.post(
|
|
1223
|
+
f"{server}/api/v1/notify/flush",
|
|
1224
|
+
json={"project": remote_cfg.repo_id, "branch": branch},
|
|
1225
|
+
headers=headers,
|
|
1226
|
+
)
|
|
1227
|
+
except Exception:
|
|
1228
|
+
pass # best-effort flush
|
|
1229
|
+
client.close()
|
|
1230
|
+
|
|
1231
|
+
print(f" Initial sync complete: {synced}/{total} files pushed.", file=sys.stderr)
|
|
1232
|
+
|
|
1233
|
+
# Push recent commit history for DB-backed commits page
|
|
1234
|
+
_push_commit_history(remote_cfg, project_dir, branch)
|
|
1235
|
+
|
|
1236
|
+
# Push blame data for recently changed files
|
|
1237
|
+
_push_blame_data(remote_cfg, project_dir, branch)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _push_commit_history(remote_cfg, project_dir: str, branch: str, limit: int = 100) -> None:
|
|
1241
|
+
"""Push recent commit metadata to the server for DB-backed commits page.
|
|
1242
|
+
|
|
1243
|
+
Includes --name-status to capture changed files per commit.
|
|
1244
|
+
"""
|
|
1245
|
+
import subprocess
|
|
1246
|
+
|
|
1247
|
+
try:
|
|
1248
|
+
import httpx
|
|
1249
|
+
except ImportError:
|
|
1250
|
+
return
|
|
1251
|
+
|
|
1252
|
+
# Use a separator to split commit header from name-status output
|
|
1253
|
+
separator = "---COMMIT_END---"
|
|
1254
|
+
try:
|
|
1255
|
+
result = subprocess.run(
|
|
1256
|
+
[
|
|
1257
|
+
"git", "log",
|
|
1258
|
+
f"--format=%H%n%s%n%an%n%ae%n%at%n%P%n{separator}",
|
|
1259
|
+
"--name-status",
|
|
1260
|
+
"-n", str(limit),
|
|
1261
|
+
],
|
|
1262
|
+
cwd=project_dir, capture_output=True, text=True, timeout=30,
|
|
1263
|
+
)
|
|
1264
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
1265
|
+
return
|
|
1266
|
+
except Exception:
|
|
1267
|
+
return
|
|
1268
|
+
|
|
1269
|
+
# Parse commits with changed files
|
|
1270
|
+
commits = []
|
|
1271
|
+
blocks = result.stdout.split(separator)
|
|
1272
|
+
|
|
1273
|
+
for block in blocks:
|
|
1274
|
+
block = block.strip()
|
|
1275
|
+
if not block:
|
|
1276
|
+
continue
|
|
1277
|
+
|
|
1278
|
+
lines = block.split("\n")
|
|
1279
|
+
if len(lines) < 6:
|
|
1280
|
+
continue
|
|
1281
|
+
|
|
1282
|
+
oid = lines[0].strip()
|
|
1283
|
+
message = lines[1].strip()
|
|
1284
|
+
author_name = lines[2].strip()
|
|
1285
|
+
author_email = lines[3].strip()
|
|
1286
|
+
timestamp_str = lines[4].strip()
|
|
1287
|
+
parent_line = lines[5].strip()
|
|
1288
|
+
|
|
1289
|
+
if not oid:
|
|
1290
|
+
continue
|
|
1291
|
+
try:
|
|
1292
|
+
ts = int(timestamp_str)
|
|
1293
|
+
except ValueError:
|
|
1294
|
+
ts = 0
|
|
1295
|
+
parent_oids = parent_line.split() if parent_line else []
|
|
1296
|
+
|
|
1297
|
+
# Parse name-status lines (after the 6 header lines)
|
|
1298
|
+
changed_files = []
|
|
1299
|
+
for ns_line in lines[6:]:
|
|
1300
|
+
ns_line = ns_line.strip()
|
|
1301
|
+
if not ns_line:
|
|
1302
|
+
continue
|
|
1303
|
+
parts = ns_line.split("\t", 1)
|
|
1304
|
+
if len(parts) == 2:
|
|
1305
|
+
status_code = parts[0].strip()
|
|
1306
|
+
file_path = parts[1].strip()
|
|
1307
|
+
# Map git status codes to our format
|
|
1308
|
+
status_map = {"A": "A", "M": "M", "D": "D", "R": "R", "C": "C"}
|
|
1309
|
+
status = status_map.get(status_code[0], status_code[0]) if status_code else "M"
|
|
1310
|
+
changed_files.append({"path": file_path, "status": status})
|
|
1311
|
+
|
|
1312
|
+
commits.append({
|
|
1313
|
+
"oid": oid,
|
|
1314
|
+
"message": message,
|
|
1315
|
+
"author_name": author_name,
|
|
1316
|
+
"author_email": author_email,
|
|
1317
|
+
"timestamp": ts,
|
|
1318
|
+
"parent_oids": parent_oids,
|
|
1319
|
+
"changed_files": changed_files if changed_files else None,
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
if not commits:
|
|
1323
|
+
return
|
|
1324
|
+
|
|
1325
|
+
server = remote_cfg.server.rstrip("/")
|
|
1326
|
+
headers = {"Authorization": f"Bearer {remote_cfg.token}"}
|
|
1327
|
+
|
|
1328
|
+
try:
|
|
1329
|
+
resp = httpx.post(
|
|
1330
|
+
f"{server}/api/v1/notify/commits",
|
|
1331
|
+
json={
|
|
1332
|
+
"commits": commits,
|
|
1333
|
+
"project": remote_cfg.repo_id,
|
|
1334
|
+
"branch": branch,
|
|
1335
|
+
},
|
|
1336
|
+
headers=headers,
|
|
1337
|
+
timeout=30,
|
|
1338
|
+
)
|
|
1339
|
+
if resp.status_code == 200:
|
|
1340
|
+
data = resp.json()
|
|
1341
|
+
print(f" Pushed {data.get('stored', 0)} commits to server.", file=sys.stderr)
|
|
1342
|
+
else:
|
|
1343
|
+
print(f" [warn] Commit push returned {resp.status_code}", file=sys.stderr)
|
|
1344
|
+
except Exception as e:
|
|
1345
|
+
print(f" [warn] Commit push failed: {e}", file=sys.stderr)
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _push_blame_data(remote_cfg, project_dir: str, branch: str, paths: list[str] | None = None) -> None:
|
|
1349
|
+
"""Push blame data for specified files (or top files) to the server."""
|
|
1350
|
+
import subprocess
|
|
1351
|
+
|
|
1352
|
+
try:
|
|
1353
|
+
import httpx
|
|
1354
|
+
except ImportError:
|
|
1355
|
+
return
|
|
1356
|
+
|
|
1357
|
+
if paths is None:
|
|
1358
|
+
# Default: blame top 20 most-recently-changed files
|
|
1359
|
+
try:
|
|
1360
|
+
result = subprocess.run(
|
|
1361
|
+
["git", "diff", "--name-only", "HEAD~10", "HEAD"],
|
|
1362
|
+
cwd=project_dir, capture_output=True, text=True, timeout=15,
|
|
1363
|
+
)
|
|
1364
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
1365
|
+
paths = result.stdout.strip().splitlines()[:20]
|
|
1366
|
+
else:
|
|
1367
|
+
return
|
|
1368
|
+
except Exception:
|
|
1369
|
+
return
|
|
1370
|
+
|
|
1371
|
+
if not paths:
|
|
1372
|
+
return
|
|
1373
|
+
|
|
1374
|
+
server = remote_cfg.server.rstrip("/")
|
|
1375
|
+
headers = {"Authorization": f"Bearer {remote_cfg.token}"}
|
|
1376
|
+
client = httpx.Client(timeout=30)
|
|
1377
|
+
pushed = 0
|
|
1378
|
+
|
|
1379
|
+
try:
|
|
1380
|
+
for file_path in paths:
|
|
1381
|
+
try:
|
|
1382
|
+
result = subprocess.run(
|
|
1383
|
+
["git", "blame", "--porcelain", file_path],
|
|
1384
|
+
cwd=project_dir, capture_output=True, text=True, timeout=30,
|
|
1385
|
+
)
|
|
1386
|
+
if result.returncode != 0:
|
|
1387
|
+
continue
|
|
1388
|
+
except Exception:
|
|
1389
|
+
continue
|
|
1390
|
+
|
|
1391
|
+
hunks = _parse_git_blame_porcelain(result.stdout)
|
|
1392
|
+
if not hunks:
|
|
1393
|
+
continue
|
|
1394
|
+
|
|
1395
|
+
try:
|
|
1396
|
+
resp = client.post(
|
|
1397
|
+
f"{server}/api/v1/notify/blame",
|
|
1398
|
+
json={
|
|
1399
|
+
"hunks": hunks,
|
|
1400
|
+
"project": remote_cfg.repo_id,
|
|
1401
|
+
"branch": branch,
|
|
1402
|
+
"path": file_path,
|
|
1403
|
+
},
|
|
1404
|
+
headers=headers,
|
|
1405
|
+
)
|
|
1406
|
+
if resp.status_code == 200:
|
|
1407
|
+
pushed += 1
|
|
1408
|
+
except Exception:
|
|
1409
|
+
pass
|
|
1410
|
+
finally:
|
|
1411
|
+
client.close()
|
|
1412
|
+
|
|
1413
|
+
if pushed:
|
|
1414
|
+
print(f" Pushed blame data for {pushed} file(s).", file=sys.stderr)
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _parse_git_blame_porcelain(output: str) -> list[dict]:
|
|
1418
|
+
"""Parse git blame --porcelain output into structured hunks."""
|
|
1419
|
+
hunks: list[dict] = []
|
|
1420
|
+
lines = output.splitlines()
|
|
1421
|
+
i = 0
|
|
1422
|
+
current: dict = {}
|
|
1423
|
+
|
|
1424
|
+
while i < len(lines):
|
|
1425
|
+
line = lines[i]
|
|
1426
|
+
# Skip content lines (porcelain format prefixes them with \t)
|
|
1427
|
+
if line.startswith("\t"):
|
|
1428
|
+
i += 1
|
|
1429
|
+
continue
|
|
1430
|
+
# Header line: <sha> <orig_line> <final_line> [<num_lines>]
|
|
1431
|
+
parts = line.split()
|
|
1432
|
+
if (
|
|
1433
|
+
len(parts) >= 3
|
|
1434
|
+
and len(parts[0]) == 40
|
|
1435
|
+
and all(c in "0123456789abcdef" for c in parts[0])
|
|
1436
|
+
and parts[1].isdigit()
|
|
1437
|
+
and parts[2].isdigit()
|
|
1438
|
+
):
|
|
1439
|
+
start_line = int(parts[2])
|
|
1440
|
+
num_lines = int(parts[3]) if len(parts) >= 4 and parts[3].isdigit() else 1
|
|
1441
|
+
if current:
|
|
1442
|
+
hunks.append(current)
|
|
1443
|
+
commit_oid = parts[0]
|
|
1444
|
+
current = {
|
|
1445
|
+
"commit_oid": commit_oid,
|
|
1446
|
+
"start_line": start_line,
|
|
1447
|
+
"end_line": start_line + num_lines - 1,
|
|
1448
|
+
"author_name": "",
|
|
1449
|
+
"author_email": "",
|
|
1450
|
+
"timestamp": 0,
|
|
1451
|
+
}
|
|
1452
|
+
elif line.startswith("author "):
|
|
1453
|
+
current["author_name"] = line[7:]
|
|
1454
|
+
elif line.startswith("author-mail "):
|
|
1455
|
+
email = line[12:].strip("<>")
|
|
1456
|
+
current["author_email"] = email
|
|
1457
|
+
elif line.startswith("author-time "):
|
|
1458
|
+
try:
|
|
1459
|
+
current["timestamp"] = int(line[12:])
|
|
1460
|
+
except ValueError:
|
|
1461
|
+
pass
|
|
1462
|
+
i += 1
|
|
1463
|
+
|
|
1464
|
+
if current:
|
|
1465
|
+
hunks.append(current)
|
|
1466
|
+
|
|
1467
|
+
# Merge consecutive hunks from the same commit
|
|
1468
|
+
merged: list[dict] = []
|
|
1469
|
+
for h in hunks:
|
|
1470
|
+
if merged and merged[-1]["commit_oid"] == h["commit_oid"] and merged[-1]["end_line"] + 1 == h["start_line"]:
|
|
1471
|
+
merged[-1]["end_line"] = h["end_line"]
|
|
1472
|
+
else:
|
|
1473
|
+
merged.append(h)
|
|
1474
|
+
|
|
1475
|
+
return merged
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def _cmd_connect(args: list[str]) -> None:
|
|
1479
|
+
"""Connect a local project to a running code-intel server.
|
|
1480
|
+
|
|
1481
|
+
Primary onboarding flow for service mode. Handles authentication
|
|
1482
|
+
(interactive register/login or explicit --token), org/repo selection,
|
|
1483
|
+
and writes .attocode/config.toml for subsequent notify/watch commands.
|
|
1484
|
+
"""
|
|
1485
|
+
from attocode_intel.config import RemoteConfig, save_remote_config
|
|
1486
|
+
|
|
1487
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
1488
|
+
project_dir = os.path.abspath(project_dir)
|
|
1489
|
+
|
|
1490
|
+
server = ""
|
|
1491
|
+
token = ""
|
|
1492
|
+
repo_id = ""
|
|
1493
|
+
email = ""
|
|
1494
|
+
password = ""
|
|
1495
|
+
org_slug = ""
|
|
1496
|
+
repo_name_override = ""
|
|
1497
|
+
ci_mode = False
|
|
1498
|
+
skip_sync = False
|
|
1499
|
+
state_file_override = ""
|
|
1500
|
+
|
|
1501
|
+
i = 0
|
|
1502
|
+
while i < len(args):
|
|
1503
|
+
arg = args[i]
|
|
1504
|
+
if arg == "--ci":
|
|
1505
|
+
ci_mode = True
|
|
1506
|
+
i += 1
|
|
1507
|
+
continue
|
|
1508
|
+
elif arg == "--skip-sync":
|
|
1509
|
+
skip_sync = True
|
|
1510
|
+
i += 1
|
|
1511
|
+
continue
|
|
1512
|
+
elif arg == "--state-file" and i + 1 < len(args):
|
|
1513
|
+
state_file_override = args[i + 1]
|
|
1514
|
+
i += 2
|
|
1515
|
+
continue
|
|
1516
|
+
elif arg.startswith("--state-file="):
|
|
1517
|
+
state_file_override = arg.split("=", 1)[1]
|
|
1518
|
+
i += 1
|
|
1519
|
+
continue
|
|
1520
|
+
elif arg == "--server" and i + 1 < len(args):
|
|
1521
|
+
server = args[i + 1]
|
|
1522
|
+
i += 2
|
|
1523
|
+
elif arg.startswith("--server="):
|
|
1524
|
+
server = arg.split("=", 1)[1]
|
|
1525
|
+
i += 1
|
|
1526
|
+
elif arg == "--token" and i + 1 < len(args):
|
|
1527
|
+
token = args[i + 1]
|
|
1528
|
+
i += 2
|
|
1529
|
+
elif arg.startswith("--token="):
|
|
1530
|
+
token = arg.split("=", 1)[1]
|
|
1531
|
+
i += 1
|
|
1532
|
+
elif arg == "--repo" and i + 1 < len(args):
|
|
1533
|
+
repo_id = args[i + 1]
|
|
1534
|
+
i += 2
|
|
1535
|
+
elif arg.startswith("--repo="):
|
|
1536
|
+
repo_id = arg.split("=", 1)[1]
|
|
1537
|
+
i += 1
|
|
1538
|
+
elif arg == "--email" and i + 1 < len(args):
|
|
1539
|
+
email = args[i + 1]
|
|
1540
|
+
i += 2
|
|
1541
|
+
elif arg.startswith("--email="):
|
|
1542
|
+
email = arg.split("=", 1)[1]
|
|
1543
|
+
i += 1
|
|
1544
|
+
elif arg == "--password" and i + 1 < len(args):
|
|
1545
|
+
password = args[i + 1]
|
|
1546
|
+
i += 2
|
|
1547
|
+
elif arg.startswith("--password="):
|
|
1548
|
+
password = arg.split("=", 1)[1]
|
|
1549
|
+
i += 1
|
|
1550
|
+
elif arg == "--org" and i + 1 < len(args):
|
|
1551
|
+
org_slug = args[i + 1]
|
|
1552
|
+
i += 2
|
|
1553
|
+
elif arg.startswith("--org="):
|
|
1554
|
+
org_slug = arg.split("=", 1)[1]
|
|
1555
|
+
i += 1
|
|
1556
|
+
elif arg == "--name" and i + 1 < len(args):
|
|
1557
|
+
repo_name_override = args[i + 1]
|
|
1558
|
+
i += 2
|
|
1559
|
+
elif arg.startswith("--name="):
|
|
1560
|
+
repo_name_override = arg.split("=", 1)[1]
|
|
1561
|
+
i += 1
|
|
1562
|
+
elif arg == "--force":
|
|
1563
|
+
# Will clear cached state below
|
|
1564
|
+
i += 1
|
|
1565
|
+
else:
|
|
1566
|
+
i += 1
|
|
1567
|
+
|
|
1568
|
+
force = "--force" in args
|
|
1569
|
+
|
|
1570
|
+
if not server:
|
|
1571
|
+
print("Error: --server <url> is required", file=sys.stderr)
|
|
1572
|
+
sys.exit(1)
|
|
1573
|
+
|
|
1574
|
+
server = server.rstrip("/")
|
|
1575
|
+
interactive = sys.stdin.isatty() and not ci_mode
|
|
1576
|
+
|
|
1577
|
+
# --- Health check ---
|
|
1578
|
+
try:
|
|
1579
|
+
import httpx
|
|
1580
|
+
except ImportError:
|
|
1581
|
+
print("Error: httpx not installed. Install with: pip install httpx", file=sys.stderr)
|
|
1582
|
+
sys.exit(1)
|
|
1583
|
+
|
|
1584
|
+
try:
|
|
1585
|
+
resp = httpx.get(f"{server}/health", timeout=10)
|
|
1586
|
+
if resp.status_code != 200:
|
|
1587
|
+
print(f"Error: server at {server} returned status {resp.status_code}", file=sys.stderr)
|
|
1588
|
+
sys.exit(1)
|
|
1589
|
+
print(f"Server reachable at {server}", file=sys.stderr)
|
|
1590
|
+
except Exception as e:
|
|
1591
|
+
print(f"Error: cannot reach server at {server}: {e}", file=sys.stderr)
|
|
1592
|
+
sys.exit(1)
|
|
1593
|
+
|
|
1594
|
+
state_path = state_file_override or os.path.join(project_dir, ".attocode", "dev-state.json")
|
|
1595
|
+
|
|
1596
|
+
# --force: nuke cached state so everything re-runs
|
|
1597
|
+
# --name without --force: just clear repo_id to add a new repo
|
|
1598
|
+
if force and os.path.isfile(state_path):
|
|
1599
|
+
os.remove(state_path)
|
|
1600
|
+
print("Cleared cached state (--force).", file=sys.stderr)
|
|
1601
|
+
elif repo_name_override and os.path.isfile(state_path):
|
|
1602
|
+
import json as _json
|
|
1603
|
+
try:
|
|
1604
|
+
_state = _json.loads(open(state_path, encoding="utf-8").read())
|
|
1605
|
+
_state.pop("repo_id", None)
|
|
1606
|
+
open(state_path, "w", encoding="utf-8").write(_json.dumps(_state, indent=2))
|
|
1607
|
+
print("Cleared cached repo_id (--name forces new repo).", file=sys.stderr)
|
|
1608
|
+
except Exception:
|
|
1609
|
+
pass
|
|
1610
|
+
|
|
1611
|
+
if token:
|
|
1612
|
+
# --- Non-interactive token path (original behavior + optional repo) ---
|
|
1613
|
+
rc = RemoteConfig(
|
|
1614
|
+
server=server,
|
|
1615
|
+
token=token,
|
|
1616
|
+
repo_id=repo_id,
|
|
1617
|
+
branch_auto_detect=True,
|
|
1618
|
+
)
|
|
1619
|
+
else:
|
|
1620
|
+
# --- Interactive (or scripted via --email/--password) auth path ---
|
|
1621
|
+
state = _bootstrap_user_and_project(
|
|
1622
|
+
api_base=server,
|
|
1623
|
+
project_dir=project_dir,
|
|
1624
|
+
state_path=state_path,
|
|
1625
|
+
email=email,
|
|
1626
|
+
password=password,
|
|
1627
|
+
org_slug=org_slug,
|
|
1628
|
+
repo_name=repo_name_override,
|
|
1629
|
+
interactive=interactive,
|
|
1630
|
+
)
|
|
1631
|
+
rc = RemoteConfig(
|
|
1632
|
+
server=server,
|
|
1633
|
+
token=state["token"],
|
|
1634
|
+
repo_id=repo_id or state.get("repo_id", ""),
|
|
1635
|
+
branch_auto_detect=True,
|
|
1636
|
+
)
|
|
1637
|
+
|
|
1638
|
+
config_path = save_remote_config(project_dir, rc)
|
|
1639
|
+
|
|
1640
|
+
# --- Auto-verify connection ---
|
|
1641
|
+
print(file=sys.stderr)
|
|
1642
|
+
errors = 0
|
|
1643
|
+
headers = {"Authorization": f"Bearer {rc.token}"}
|
|
1644
|
+
try:
|
|
1645
|
+
resp = httpx.get(f"{server}/api/v1/auth/me", headers=headers, timeout=10)
|
|
1646
|
+
if resp.status_code == 200:
|
|
1647
|
+
me = resp.json()
|
|
1648
|
+
print(f" [ok] Authenticated as {me.get('email', '?')}", file=sys.stderr)
|
|
1649
|
+
else:
|
|
1650
|
+
print(f" [FAIL] Auth check returned {resp.status_code}", file=sys.stderr)
|
|
1651
|
+
errors += 1
|
|
1652
|
+
except Exception as e:
|
|
1653
|
+
print(f" [FAIL] Auth check: {e}", file=sys.stderr)
|
|
1654
|
+
errors += 1
|
|
1655
|
+
|
|
1656
|
+
if rc.repo_id:
|
|
1657
|
+
try:
|
|
1658
|
+
resp = httpx.get(
|
|
1659
|
+
f"{server}/api/v2/repos/{rc.repo_id}/files",
|
|
1660
|
+
headers=headers,
|
|
1661
|
+
params={"limit": "1"},
|
|
1662
|
+
timeout=10,
|
|
1663
|
+
)
|
|
1664
|
+
if resp.status_code == 200:
|
|
1665
|
+
print(f" [ok] Repo {rc.repo_id} accessible", file=sys.stderr)
|
|
1666
|
+
else:
|
|
1667
|
+
print(f" [FAIL] Repo check returned {resp.status_code}", file=sys.stderr)
|
|
1668
|
+
errors += 1
|
|
1669
|
+
except Exception as e:
|
|
1670
|
+
print(f" [FAIL] Repo check: {e}", file=sys.stderr)
|
|
1671
|
+
errors += 1
|
|
1672
|
+
|
|
1673
|
+
# --- Initial sync: push all tracked files so the server has content ---
|
|
1674
|
+
if rc.repo_id and not errors and not skip_sync:
|
|
1675
|
+
_initial_sync(rc, project_dir)
|
|
1676
|
+
|
|
1677
|
+
# --- Summary ---
|
|
1678
|
+
print(
|
|
1679
|
+
f"\nConnection saved to {config_path}\n"
|
|
1680
|
+
f" Server: {rc.server}\n"
|
|
1681
|
+
f" Repo ID: {rc.repo_id or '(auto-detect)'}\n",
|
|
1682
|
+
file=sys.stderr,
|
|
1683
|
+
)
|
|
1684
|
+
if errors:
|
|
1685
|
+
print(
|
|
1686
|
+
"Some checks failed. Run 'attocode code-intel test-connection' for details.",
|
|
1687
|
+
file=sys.stderr,
|
|
1688
|
+
)
|
|
1689
|
+
else:
|
|
1690
|
+
print(
|
|
1691
|
+
"Next steps:\n"
|
|
1692
|
+
" attocode code-intel watch # auto-notify on file save\n"
|
|
1693
|
+
" attocode code-intel install claude # install MCP server\n",
|
|
1694
|
+
file=sys.stderr,
|
|
1695
|
+
)
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def _cmd_test_connection(args: list[str]) -> None:
|
|
1699
|
+
"""Verify connectivity to the remote code-intel server.
|
|
1700
|
+
|
|
1701
|
+
Checks: server reachable, auth valid, repo exists, notify endpoint works,
|
|
1702
|
+
WebSocket connection, index stats.
|
|
1703
|
+
"""
|
|
1704
|
+
import subprocess
|
|
1705
|
+
|
|
1706
|
+
try:
|
|
1707
|
+
import httpx
|
|
1708
|
+
except ImportError:
|
|
1709
|
+
print("Error: httpx not installed. Install with: pip install httpx", file=sys.stderr)
|
|
1710
|
+
sys.exit(1)
|
|
1711
|
+
|
|
1712
|
+
from attocode_intel.config import load_remote_config
|
|
1713
|
+
|
|
1714
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
1715
|
+
project_dir = os.path.abspath(project_dir)
|
|
1716
|
+
|
|
1717
|
+
rc = load_remote_config(project_dir)
|
|
1718
|
+
if not rc.is_configured:
|
|
1719
|
+
print("Error: no remote connection configured.", file=sys.stderr)
|
|
1720
|
+
print("Run 'attocode code-intel connect --server <url>' first.", file=sys.stderr)
|
|
1721
|
+
sys.exit(1)
|
|
1722
|
+
|
|
1723
|
+
server = rc.server.rstrip("/")
|
|
1724
|
+
headers = {"Authorization": f"Bearer {rc.token}"}
|
|
1725
|
+
errors = 0
|
|
1726
|
+
|
|
1727
|
+
# 1. Server reachable
|
|
1728
|
+
try:
|
|
1729
|
+
resp = httpx.get(f"{server}/health", timeout=10)
|
|
1730
|
+
if resp.status_code == 200:
|
|
1731
|
+
print(f" [ok] Server reachable at {server}")
|
|
1732
|
+
else:
|
|
1733
|
+
print(f" [FAIL] Server returned {resp.status_code}")
|
|
1734
|
+
errors += 1
|
|
1735
|
+
except Exception as e:
|
|
1736
|
+
print(f" [FAIL] Cannot connect to {server}: {e}")
|
|
1737
|
+
errors += 1
|
|
1738
|
+
# Can't continue without connectivity
|
|
1739
|
+
print(f"\n{errors} check(s) failed.")
|
|
1740
|
+
sys.exit(1)
|
|
1741
|
+
|
|
1742
|
+
# 2. Authentication valid
|
|
1743
|
+
try:
|
|
1744
|
+
resp = httpx.get(f"{server}/api/v1/auth/me", headers=headers, timeout=10)
|
|
1745
|
+
if resp.status_code == 200:
|
|
1746
|
+
data = resp.json()
|
|
1747
|
+
email = data.get("email", data.get("username", "unknown"))
|
|
1748
|
+
print(f" [ok] Authentication valid (user: {email})")
|
|
1749
|
+
else:
|
|
1750
|
+
print(f" [FAIL] Authentication failed ({resp.status_code})")
|
|
1751
|
+
errors += 1
|
|
1752
|
+
except Exception as e:
|
|
1753
|
+
print(f" [FAIL] Auth check error: {e}")
|
|
1754
|
+
errors += 1
|
|
1755
|
+
|
|
1756
|
+
# 3. Repository registered
|
|
1757
|
+
if rc.repo_id:
|
|
1758
|
+
try:
|
|
1759
|
+
resp = httpx.get(
|
|
1760
|
+
f"{server}/api/v1/repos/{rc.repo_id}",
|
|
1761
|
+
headers=headers, timeout=10,
|
|
1762
|
+
)
|
|
1763
|
+
if resp.status_code == 200:
|
|
1764
|
+
data = resp.json()
|
|
1765
|
+
repo_name = data.get("name", rc.repo_id)
|
|
1766
|
+
print(f" [ok] Repository registered ({repo_name}, repo_id: {rc.repo_id})")
|
|
1767
|
+
else:
|
|
1768
|
+
print(f" [FAIL] Repository not found ({resp.status_code})")
|
|
1769
|
+
errors += 1
|
|
1770
|
+
except Exception as e:
|
|
1771
|
+
print(f" [FAIL] Repo check error: {e}")
|
|
1772
|
+
errors += 1
|
|
1773
|
+
else:
|
|
1774
|
+
print(" [skip] No repo_id configured — use --repo to set")
|
|
1775
|
+
|
|
1776
|
+
# 4. Branch tracked
|
|
1777
|
+
branch = "main"
|
|
1778
|
+
try:
|
|
1779
|
+
result = subprocess.run(
|
|
1780
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
1781
|
+
cwd=project_dir,
|
|
1782
|
+
capture_output=True, text=True, timeout=5,
|
|
1783
|
+
)
|
|
1784
|
+
if result.returncode == 0:
|
|
1785
|
+
branch = result.stdout.strip()
|
|
1786
|
+
except Exception:
|
|
1787
|
+
pass
|
|
1788
|
+
|
|
1789
|
+
if rc.repo_id:
|
|
1790
|
+
try:
|
|
1791
|
+
resp = httpx.get(
|
|
1792
|
+
f"{server}/api/v1/repos/{rc.repo_id}/branches",
|
|
1793
|
+
headers=headers, timeout=10,
|
|
1794
|
+
)
|
|
1795
|
+
if resp.status_code == 200:
|
|
1796
|
+
branches = resp.json()
|
|
1797
|
+
names = [b.get("name") for b in branches] if isinstance(branches, list) else []
|
|
1798
|
+
if branch in names:
|
|
1799
|
+
print(f" [ok] Branch tracked ({branch})")
|
|
1800
|
+
else:
|
|
1801
|
+
print(f" [warn] Branch '{branch}' not yet tracked (will be auto-created on first notify)")
|
|
1802
|
+
else:
|
|
1803
|
+
print(f" [skip] Could not check branches ({resp.status_code})")
|
|
1804
|
+
except Exception:
|
|
1805
|
+
print(" [skip] Branch check skipped")
|
|
1806
|
+
|
|
1807
|
+
# 5. Notify endpoint accepts changes
|
|
1808
|
+
try:
|
|
1809
|
+
resp = httpx.post(
|
|
1810
|
+
f"{server}/api/v1/notify/file-changed",
|
|
1811
|
+
json={"paths": [], "project": rc.repo_id, "branch": branch},
|
|
1812
|
+
headers=headers, timeout=10,
|
|
1813
|
+
)
|
|
1814
|
+
if resp.status_code in (200, 202, 422):
|
|
1815
|
+
# 422 is expected with empty paths — endpoint is reachable
|
|
1816
|
+
print(" [ok] Notify endpoint accepts changes")
|
|
1817
|
+
else:
|
|
1818
|
+
print(f" [FAIL] Notify endpoint returned {resp.status_code}")
|
|
1819
|
+
errors += 1
|
|
1820
|
+
except Exception as e:
|
|
1821
|
+
print(f" [FAIL] Notify endpoint error: {e}")
|
|
1822
|
+
errors += 1
|
|
1823
|
+
|
|
1824
|
+
# 6. WebSocket connection
|
|
1825
|
+
try:
|
|
1826
|
+
import websockets.sync.client # type: ignore[import-untyped]
|
|
1827
|
+
|
|
1828
|
+
ws_url = server.replace("https://", "wss://").replace("http://", "ws://")
|
|
1829
|
+
if rc.repo_id:
|
|
1830
|
+
ws_url = f"{ws_url}/ws/repos/{rc.repo_id}/events?token={rc.token}"
|
|
1831
|
+
ws = websockets.sync.client.connect(ws_url, close_timeout=3, open_timeout=5)
|
|
1832
|
+
ws.close()
|
|
1833
|
+
print(" [ok] WebSocket connection established")
|
|
1834
|
+
else:
|
|
1835
|
+
print(" [skip] WebSocket check requires repo_id")
|
|
1836
|
+
except ImportError:
|
|
1837
|
+
print(" [skip] WebSocket check requires 'websockets' package")
|
|
1838
|
+
except Exception as e:
|
|
1839
|
+
print(f" [warn] WebSocket connection failed: {e}")
|
|
1840
|
+
|
|
1841
|
+
# 7. Index stats
|
|
1842
|
+
if rc.repo_id:
|
|
1843
|
+
try:
|
|
1844
|
+
resp = httpx.get(
|
|
1845
|
+
f"{server}/api/v2/repos/{rc.repo_id}/stats",
|
|
1846
|
+
headers=headers, timeout=10,
|
|
1847
|
+
)
|
|
1848
|
+
if resp.status_code == 200:
|
|
1849
|
+
data = resp.json()
|
|
1850
|
+
files = data.get("total_files", "?")
|
|
1851
|
+
embedded = data.get("embedded_files", "?")
|
|
1852
|
+
print(f" [ok] {files} files indexed, {embedded} embedded")
|
|
1853
|
+
else:
|
|
1854
|
+
print(" [skip] Could not retrieve index stats")
|
|
1855
|
+
except Exception:
|
|
1856
|
+
print(" [skip] Index stats check skipped")
|
|
1857
|
+
|
|
1858
|
+
print()
|
|
1859
|
+
if errors == 0:
|
|
1860
|
+
print("All checks passed!")
|
|
1861
|
+
else:
|
|
1862
|
+
print(f"{errors} check(s) failed.")
|
|
1863
|
+
sys.exit(1)
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
def _cmd_watch(args: list[str]) -> None:
|
|
1867
|
+
"""Watch filesystem for changes and notify the remote server.
|
|
1868
|
+
|
|
1869
|
+
Uses watchfiles (Rust-based, fast) to detect changes and batch-POSTs
|
|
1870
|
+
them to the remote server's notify endpoint.
|
|
1871
|
+
"""
|
|
1872
|
+
import signal
|
|
1873
|
+
import subprocess
|
|
1874
|
+
|
|
1875
|
+
try:
|
|
1876
|
+
import httpx
|
|
1877
|
+
except ImportError:
|
|
1878
|
+
print("Error: httpx not installed. Install with: pip install httpx", file=sys.stderr)
|
|
1879
|
+
sys.exit(1)
|
|
1880
|
+
|
|
1881
|
+
try:
|
|
1882
|
+
from watchfiles import Change, watch
|
|
1883
|
+
except ImportError:
|
|
1884
|
+
print(
|
|
1885
|
+
"Error: watchfiles not installed. Install with: pip install watchfiles",
|
|
1886
|
+
file=sys.stderr,
|
|
1887
|
+
)
|
|
1888
|
+
sys.exit(1)
|
|
1889
|
+
|
|
1890
|
+
from pathlib import Path
|
|
1891
|
+
|
|
1892
|
+
from attocode_intel.config import load_remote_config
|
|
1893
|
+
|
|
1894
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
1895
|
+
project_dir = os.path.abspath(project_dir)
|
|
1896
|
+
|
|
1897
|
+
# Parse debounce
|
|
1898
|
+
debounce_ms = 500
|
|
1899
|
+
for i, arg in enumerate(args):
|
|
1900
|
+
if arg == "--debounce" and i + 1 < len(args):
|
|
1901
|
+
debounce_ms = int(args[i + 1])
|
|
1902
|
+
elif arg.startswith("--debounce="):
|
|
1903
|
+
debounce_ms = int(arg.split("=", 1)[1])
|
|
1904
|
+
|
|
1905
|
+
rc = load_remote_config(project_dir)
|
|
1906
|
+
if not rc.is_configured:
|
|
1907
|
+
print("Error: no remote connection configured.", file=sys.stderr)
|
|
1908
|
+
print("Run 'attocode code-intel connect --server <url>' first.", file=sys.stderr)
|
|
1909
|
+
sys.exit(1)
|
|
1910
|
+
|
|
1911
|
+
# Detect branch
|
|
1912
|
+
branch = "main"
|
|
1913
|
+
try:
|
|
1914
|
+
result = subprocess.run(
|
|
1915
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
1916
|
+
cwd=project_dir,
|
|
1917
|
+
capture_output=True, text=True, timeout=5,
|
|
1918
|
+
)
|
|
1919
|
+
if result.returncode == 0:
|
|
1920
|
+
branch = result.stdout.strip()
|
|
1921
|
+
except Exception:
|
|
1922
|
+
pass
|
|
1923
|
+
|
|
1924
|
+
code_extensions = {
|
|
1925
|
+
".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".rs", ".java",
|
|
1926
|
+
".rb", ".c", ".cpp", ".h", ".hpp", ".cs", ".swift", ".kt",
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
import base64
|
|
1930
|
+
|
|
1931
|
+
server = rc.server.rstrip("/")
|
|
1932
|
+
headers = {"Authorization": f"Bearer {rc.token}"}
|
|
1933
|
+
client = httpx.Client(timeout=30)
|
|
1934
|
+
|
|
1935
|
+
# Run initial sync to catch up any files not yet pushed
|
|
1936
|
+
_initial_sync(rc, project_dir)
|
|
1937
|
+
|
|
1938
|
+
stop_event = __import__("threading").Event()
|
|
1939
|
+
signal.signal(signal.SIGINT, lambda *_: stop_event.set())
|
|
1940
|
+
signal.signal(signal.SIGTERM, lambda *_: stop_event.set())
|
|
1941
|
+
|
|
1942
|
+
print(f"Watching {project_dir} for changes (branch: {branch}, debounce: {debounce_ms}ms)")
|
|
1943
|
+
print(f"Server: {server}")
|
|
1944
|
+
print("Press Ctrl+C to stop.\n")
|
|
1945
|
+
|
|
1946
|
+
try:
|
|
1947
|
+
for changes in watch(
|
|
1948
|
+
project_dir,
|
|
1949
|
+
stop_event=stop_event,
|
|
1950
|
+
recursive=True,
|
|
1951
|
+
debounce=debounce_ms,
|
|
1952
|
+
watch_filter=lambda _, path: (
|
|
1953
|
+
not any(
|
|
1954
|
+
part.startswith(".") or part in ("node_modules", "__pycache__", ".git")
|
|
1955
|
+
for part in Path(path).parts
|
|
1956
|
+
)
|
|
1957
|
+
and Path(path).suffix.lower() in code_extensions
|
|
1958
|
+
),
|
|
1959
|
+
):
|
|
1960
|
+
if stop_event.is_set():
|
|
1961
|
+
break
|
|
1962
|
+
|
|
1963
|
+
# Re-detect branch (may have changed)
|
|
1964
|
+
try:
|
|
1965
|
+
result = subprocess.run(
|
|
1966
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
1967
|
+
cwd=project_dir,
|
|
1968
|
+
capture_output=True, text=True, timeout=5,
|
|
1969
|
+
)
|
|
1970
|
+
if result.returncode == 0:
|
|
1971
|
+
branch = result.stdout.strip()
|
|
1972
|
+
except Exception:
|
|
1973
|
+
pass
|
|
1974
|
+
|
|
1975
|
+
rel_paths = []
|
|
1976
|
+
for change_type, path_str in changes:
|
|
1977
|
+
if change_type in (Change.modified, Change.added, Change.deleted):
|
|
1978
|
+
try:
|
|
1979
|
+
rel = os.path.relpath(path_str, project_dir)
|
|
1980
|
+
rel_paths.append(rel)
|
|
1981
|
+
except ValueError:
|
|
1982
|
+
pass
|
|
1983
|
+
|
|
1984
|
+
if not rel_paths:
|
|
1985
|
+
continue
|
|
1986
|
+
|
|
1987
|
+
# Read file contents and base64-encode them
|
|
1988
|
+
files: dict[str, str] = {}
|
|
1989
|
+
for rel_path in rel_paths:
|
|
1990
|
+
full = os.path.join(project_dir, rel_path)
|
|
1991
|
+
if os.path.isfile(full):
|
|
1992
|
+
try:
|
|
1993
|
+
with open(full, "rb") as f:
|
|
1994
|
+
files[rel_path] = base64.b64encode(f.read()).decode("ascii")
|
|
1995
|
+
except (OSError, PermissionError):
|
|
1996
|
+
pass # deleted or unreadable — omit, server treats as deleted
|
|
1997
|
+
|
|
1998
|
+
try:
|
|
1999
|
+
resp = client.post(
|
|
2000
|
+
f"{server}/api/v1/notify/file-changed",
|
|
2001
|
+
json={
|
|
2002
|
+
"paths": rel_paths,
|
|
2003
|
+
"files": files,
|
|
2004
|
+
"project": rc.repo_id,
|
|
2005
|
+
"branch": branch,
|
|
2006
|
+
},
|
|
2007
|
+
headers=headers,
|
|
2008
|
+
)
|
|
2009
|
+
if resp.status_code in (200, 202):
|
|
2010
|
+
print(f" Notified: {len(rel_paths)} file(s) on {branch}")
|
|
2011
|
+
else:
|
|
2012
|
+
print(f" Error: server returned {resp.status_code}", file=sys.stderr)
|
|
2013
|
+
except Exception as e:
|
|
2014
|
+
print(f" Error: {e}", file=sys.stderr)
|
|
2015
|
+
|
|
2016
|
+
except KeyboardInterrupt:
|
|
2017
|
+
pass
|
|
2018
|
+
finally:
|
|
2019
|
+
client.close()
|
|
2020
|
+
print("\nStopped watching.")
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
def _cmd_index(args: list[str]) -> None:
|
|
2024
|
+
"""Build or check the semantic search embedding index.
|
|
2025
|
+
|
|
2026
|
+
Modes:
|
|
2027
|
+
--status Print current index status and exit
|
|
2028
|
+
--foreground Blocking full index (default if no --background)
|
|
2029
|
+
--background Start daemon, poll progress, print status
|
|
2030
|
+
"""
|
|
2031
|
+
import time
|
|
2032
|
+
|
|
2033
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
2034
|
+
project_dir = os.path.abspath(project_dir)
|
|
2035
|
+
|
|
2036
|
+
mode = "foreground"
|
|
2037
|
+
for arg in args:
|
|
2038
|
+
if arg == "--status":
|
|
2039
|
+
mode = "status"
|
|
2040
|
+
elif arg == "--background":
|
|
2041
|
+
mode = "background"
|
|
2042
|
+
elif arg == "--foreground":
|
|
2043
|
+
mode = "foreground"
|
|
2044
|
+
|
|
2045
|
+
os.environ.setdefault("ATTOCODE_PROJECT_DIR", project_dir)
|
|
2046
|
+
|
|
2047
|
+
from attocode_intel._internal.integrations.context.semantic_search import SemanticSearchManager
|
|
2048
|
+
|
|
2049
|
+
mgr = SemanticSearchManager(root_dir=project_dir)
|
|
2050
|
+
|
|
2051
|
+
if mode == "status":
|
|
2052
|
+
progress = mgr.get_index_progress()
|
|
2053
|
+
print(f"Provider: {mgr.provider_name}")
|
|
2054
|
+
print(f"Status: {progress.status}")
|
|
2055
|
+
print(f"Coverage: {progress.coverage:.0%} ({progress.indexed_files}/{progress.total_files} files)")
|
|
2056
|
+
if progress.elapsed_seconds > 0:
|
|
2057
|
+
print(f"Elapsed: {progress.elapsed_seconds:.1f}s")
|
|
2058
|
+
print(f"Vector search active: {mgr.is_index_ready()}")
|
|
2059
|
+
mgr.close()
|
|
2060
|
+
return
|
|
2061
|
+
|
|
2062
|
+
if not mgr.is_available:
|
|
2063
|
+
print(
|
|
2064
|
+
"No embedding provider available. Install the semantic extra:\n"
|
|
2065
|
+
" uv tool install 'attocode-code-intel[semantic]'",
|
|
2066
|
+
file=sys.stderr,
|
|
2067
|
+
)
|
|
2068
|
+
mgr.close()
|
|
2069
|
+
sys.exit(1)
|
|
2070
|
+
|
|
2071
|
+
if mode == "foreground":
|
|
2072
|
+
print(f"Indexing {project_dir} (foreground)...", file=sys.stderr)
|
|
2073
|
+
count = mgr.index()
|
|
2074
|
+
print(f"Indexed {count} chunks.", file=sys.stderr)
|
|
2075
|
+
mgr.close()
|
|
2076
|
+
return
|
|
2077
|
+
|
|
2078
|
+
# Parse --timeout flag (default: 30 minutes)
|
|
2079
|
+
timeout_seconds = 1800
|
|
2080
|
+
for i, arg in enumerate(args):
|
|
2081
|
+
if arg == "--timeout" and i + 1 < len(args):
|
|
2082
|
+
timeout_seconds = int(args[i + 1])
|
|
2083
|
+
elif arg.startswith("--timeout="):
|
|
2084
|
+
timeout_seconds = int(arg.split("=", 1)[1])
|
|
2085
|
+
|
|
2086
|
+
# Background mode
|
|
2087
|
+
print(f"Starting background indexing for {project_dir}...", file=sys.stderr)
|
|
2088
|
+
progress = mgr.start_background_indexing()
|
|
2089
|
+
deadline = time.time() + timeout_seconds
|
|
2090
|
+
|
|
2091
|
+
try:
|
|
2092
|
+
while progress.status == "running":
|
|
2093
|
+
if time.time() > deadline:
|
|
2094
|
+
print(
|
|
2095
|
+
f"\nTimeout: indexing did not complete within {timeout_seconds}s. "
|
|
2096
|
+
"Use --timeout to increase.",
|
|
2097
|
+
file=sys.stderr,
|
|
2098
|
+
)
|
|
2099
|
+
mgr.stop_background_indexing()
|
|
2100
|
+
break
|
|
2101
|
+
progress = mgr.get_index_progress()
|
|
2102
|
+
print(
|
|
2103
|
+
f"\r {progress.indexed_files}/{progress.total_files} files "
|
|
2104
|
+
f"({progress.coverage:.0%}) — {progress.elapsed_seconds:.0f}s",
|
|
2105
|
+
end="", file=sys.stderr, flush=True,
|
|
2106
|
+
)
|
|
2107
|
+
time.sleep(1.0)
|
|
2108
|
+
except KeyboardInterrupt:
|
|
2109
|
+
print("\nStopping...", file=sys.stderr)
|
|
2110
|
+
mgr.stop_background_indexing()
|
|
2111
|
+
|
|
2112
|
+
progress = mgr.get_index_progress()
|
|
2113
|
+
print(f"\nDone: {progress.status} ({progress.indexed_files}/{progress.total_files} files)", file=sys.stderr)
|
|
2114
|
+
mgr.close()
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
# ---------------------------------------------------------------------------
|
|
2118
|
+
# setup command helpers
|
|
2119
|
+
# ---------------------------------------------------------------------------
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
def _load_env_file(path: str) -> dict[str, str]:
|
|
2123
|
+
"""Parse a .env file into os.environ. Returns parsed key-value pairs."""
|
|
2124
|
+
import re
|
|
2125
|
+
|
|
2126
|
+
pairs: dict[str, str] = {}
|
|
2127
|
+
with open(path, encoding="utf-8") as fh:
|
|
2128
|
+
for line in fh:
|
|
2129
|
+
line = line.strip()
|
|
2130
|
+
if not line or line.startswith("#"):
|
|
2131
|
+
continue
|
|
2132
|
+
m = re.match(r"([A-Za-z_][A-Za-z0-9_]*)=(.*)", line)
|
|
2133
|
+
if not m:
|
|
2134
|
+
continue
|
|
2135
|
+
key, val = m.group(1), m.group(2)
|
|
2136
|
+
# Strip surrounding quotes
|
|
2137
|
+
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
|
|
2138
|
+
val = val[1:-1]
|
|
2139
|
+
pairs[key] = val
|
|
2140
|
+
os.environ[key] = val
|
|
2141
|
+
return pairs
|
|
2142
|
+
|
|
2143
|
+
|
|
2144
|
+
def _run(
|
|
2145
|
+
cmd: list[str], *, check: bool = True, capture: bool = False,
|
|
2146
|
+
) -> "subprocess.CompletedProcess[str]":
|
|
2147
|
+
"""Run a subprocess with visible logging."""
|
|
2148
|
+
import subprocess
|
|
2149
|
+
|
|
2150
|
+
print(f"$ {' '.join(cmd)}", file=sys.stderr)
|
|
2151
|
+
return subprocess.run(
|
|
2152
|
+
cmd,
|
|
2153
|
+
check=check,
|
|
2154
|
+
capture_output=capture,
|
|
2155
|
+
text=True,
|
|
2156
|
+
)
|
|
2157
|
+
|
|
2158
|
+
|
|
2159
|
+
def _detect_default_branch(project_dir: str) -> str:
|
|
2160
|
+
"""Detect the git default branch via symbolic-ref, fallback 'main'."""
|
|
2161
|
+
import subprocess
|
|
2162
|
+
|
|
2163
|
+
try:
|
|
2164
|
+
result = subprocess.run(
|
|
2165
|
+
["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
|
|
2166
|
+
cwd=project_dir,
|
|
2167
|
+
capture_output=True, text=True, timeout=5,
|
|
2168
|
+
)
|
|
2169
|
+
if result.returncode == 0:
|
|
2170
|
+
# refs/remotes/origin/main -> main
|
|
2171
|
+
return result.stdout.strip().rsplit("/", 1)[-1]
|
|
2172
|
+
except Exception:
|
|
2173
|
+
pass
|
|
2174
|
+
return "main"
|
|
2175
|
+
|
|
2176
|
+
|
|
2177
|
+
def _cmd_setup(args: list[str]) -> None:
|
|
2178
|
+
"""[Dev] Bootstrap local infrastructure for contributing to attocode.
|
|
2179
|
+
|
|
2180
|
+
Phase 1 (infra): Docker, deps, migrations — no API needed.
|
|
2181
|
+
Phase 2 (bootstrap): Register dev user, create org, add repo — requires running API.
|
|
2182
|
+
|
|
2183
|
+
To connect to an existing server, use ``attocode code-intel connect`` instead.
|
|
2184
|
+
"""
|
|
2185
|
+
|
|
2186
|
+
try:
|
|
2187
|
+
import httpx
|
|
2188
|
+
except ImportError:
|
|
2189
|
+
print(
|
|
2190
|
+
"Error: httpx not installed. Install with: uv sync --extra service",
|
|
2191
|
+
file=sys.stderr,
|
|
2192
|
+
)
|
|
2193
|
+
sys.exit(1)
|
|
2194
|
+
|
|
2195
|
+
# --- parse flags ---
|
|
2196
|
+
reset = False
|
|
2197
|
+
skip_deps = False
|
|
2198
|
+
project_dir = "."
|
|
2199
|
+
|
|
2200
|
+
i = 0
|
|
2201
|
+
while i < len(args):
|
|
2202
|
+
arg = args[i]
|
|
2203
|
+
if arg == "--reset":
|
|
2204
|
+
reset = True
|
|
2205
|
+
i += 1
|
|
2206
|
+
elif arg == "--skip-deps":
|
|
2207
|
+
skip_deps = True
|
|
2208
|
+
i += 1
|
|
2209
|
+
elif arg == "--project" and i + 1 < len(args):
|
|
2210
|
+
project_dir = args[i + 1]
|
|
2211
|
+
i += 2
|
|
2212
|
+
elif arg.startswith("--project="):
|
|
2213
|
+
project_dir = arg.split("=", 1)[1]
|
|
2214
|
+
i += 1
|
|
2215
|
+
else:
|
|
2216
|
+
i += 1
|
|
2217
|
+
|
|
2218
|
+
project_dir = os.path.abspath(project_dir)
|
|
2219
|
+
compose_file = os.path.join(project_dir, "docker", "code-intel", "docker-compose.dev.yml")
|
|
2220
|
+
if not os.path.isfile(compose_file):
|
|
2221
|
+
print(
|
|
2222
|
+
"Error: setup works only in a clone of the attocode repository "
|
|
2223
|
+
f"({compose_file} not found). Use --project to point at the clone.",
|
|
2224
|
+
file=sys.stderr,
|
|
2225
|
+
)
|
|
2226
|
+
sys.exit(1)
|
|
2227
|
+
env_dev = os.path.join(project_dir, ".env.dev")
|
|
2228
|
+
state_path = os.path.join(project_dir, ".attocode", "dev-state.json")
|
|
2229
|
+
alembic_ini = os.path.join(
|
|
2230
|
+
project_dir, "src", "attocode", "code_intel", "migrations", "alembic.ini",
|
|
2231
|
+
)
|
|
2232
|
+
|
|
2233
|
+
# =========================================================
|
|
2234
|
+
# Phase 1 — Infrastructure (no API needed)
|
|
2235
|
+
# =========================================================
|
|
2236
|
+
print("\n=== Phase 1: Infrastructure ===\n", file=sys.stderr)
|
|
2237
|
+
|
|
2238
|
+
# 1. Reset if requested
|
|
2239
|
+
if reset:
|
|
2240
|
+
print("Resetting...", file=sys.stderr)
|
|
2241
|
+
_run(
|
|
2242
|
+
["docker", "compose", "-f", compose_file, "down", "-v"],
|
|
2243
|
+
check=False,
|
|
2244
|
+
)
|
|
2245
|
+
if os.path.exists(state_path):
|
|
2246
|
+
os.remove(state_path)
|
|
2247
|
+
print(f"Removed {state_path}", file=sys.stderr)
|
|
2248
|
+
|
|
2249
|
+
# 2. Check prerequisites
|
|
2250
|
+
for tool in ("docker", "uv"):
|
|
2251
|
+
if not shutil.which(tool):
|
|
2252
|
+
print(f"Error: '{tool}' not found on PATH.", file=sys.stderr)
|
|
2253
|
+
sys.exit(1)
|
|
2254
|
+
|
|
2255
|
+
for required in (compose_file, env_dev):
|
|
2256
|
+
if not os.path.isfile(required):
|
|
2257
|
+
print(f"Error: required file not found: {required}", file=sys.stderr)
|
|
2258
|
+
sys.exit(1)
|
|
2259
|
+
|
|
2260
|
+
# 3. Start Postgres + Redis
|
|
2261
|
+
_run(["docker", "compose", "-f", compose_file, "up", "-d", "--wait"])
|
|
2262
|
+
|
|
2263
|
+
# 4. Install deps
|
|
2264
|
+
if not skip_deps:
|
|
2265
|
+
_run(["uv", "sync", "--extra", "service", "--extra", "semantic"])
|
|
2266
|
+
|
|
2267
|
+
# 5. Load .env.dev
|
|
2268
|
+
env_vars = _load_env_file(env_dev)
|
|
2269
|
+
print(f"Loaded {len(env_vars)} env vars from {env_dev}", file=sys.stderr)
|
|
2270
|
+
|
|
2271
|
+
# 6. Run migrations
|
|
2272
|
+
_run(["uv", "run", "alembic", "-c", alembic_ini, "upgrade", "head"])
|
|
2273
|
+
|
|
2274
|
+
print("\nPhase 1 complete.", file=sys.stderr)
|
|
2275
|
+
|
|
2276
|
+
# =========================================================
|
|
2277
|
+
# Phase 2 — Bootstrap (requires running API)
|
|
2278
|
+
# =========================================================
|
|
2279
|
+
print("\n=== Phase 2: Bootstrap ===\n", file=sys.stderr)
|
|
2280
|
+
|
|
2281
|
+
api_base = f"http://127.0.0.1:{env_vars.get('ATTOCODE_PORT', '8080')}"
|
|
2282
|
+
|
|
2283
|
+
# 7. Health check
|
|
2284
|
+
try:
|
|
2285
|
+
resp = httpx.get(f"{api_base}/health", timeout=5)
|
|
2286
|
+
if resp.status_code != 200:
|
|
2287
|
+
raise httpx.ConnectError("unhealthy")
|
|
2288
|
+
except Exception:
|
|
2289
|
+
print(
|
|
2290
|
+
f"API server not reachable at {api_base}.\n"
|
|
2291
|
+
"Start it in another terminal:\n\n"
|
|
2292
|
+
f" source {env_dev} && uvicorn attocode_intel.api.app:create_app "
|
|
2293
|
+
"--factory --reload --port 8080\n\n"
|
|
2294
|
+
"Then re-run: attocode code-intel setup\n",
|
|
2295
|
+
file=sys.stderr,
|
|
2296
|
+
)
|
|
2297
|
+
sys.exit(0)
|
|
2298
|
+
|
|
2299
|
+
print(f"API reachable at {api_base}", file=sys.stderr)
|
|
2300
|
+
|
|
2301
|
+
# 8. Register dev user, create org, add repo (shared helper)
|
|
2302
|
+
state = _bootstrap_user_and_project(
|
|
2303
|
+
api_base=api_base,
|
|
2304
|
+
project_dir=project_dir,
|
|
2305
|
+
state_path=state_path,
|
|
2306
|
+
email="dev@localhost",
|
|
2307
|
+
password="dev-password-123",
|
|
2308
|
+
org_slug="dev-org",
|
|
2309
|
+
interactive=False,
|
|
2310
|
+
)
|
|
2311
|
+
|
|
2312
|
+
# 9. Write .attocode/config.toml for notify/watch
|
|
2313
|
+
from attocode_intel.config import RemoteConfig, save_remote_config
|
|
2314
|
+
|
|
2315
|
+
rc = RemoteConfig(
|
|
2316
|
+
server=api_base,
|
|
2317
|
+
token=state["token"],
|
|
2318
|
+
repo_id=state["repo_id"],
|
|
2319
|
+
branch_auto_detect=True,
|
|
2320
|
+
)
|
|
2321
|
+
config_path = save_remote_config(project_dir, rc)
|
|
2322
|
+
|
|
2323
|
+
# 10. Summary
|
|
2324
|
+
print(
|
|
2325
|
+
f"\n=== Setup complete ===\n\n"
|
|
2326
|
+
f" API: {api_base}\n"
|
|
2327
|
+
f" Org: {state['org_id']}\n"
|
|
2328
|
+
f" Repo: {state['repo_id']}\n"
|
|
2329
|
+
f" Config: {config_path}\n\n"
|
|
2330
|
+
"Next steps:\n"
|
|
2331
|
+
f" # Worker (processes indexing jobs)\n"
|
|
2332
|
+
f" source {env_dev} && python -m attocode_intel.workers.run\n\n"
|
|
2333
|
+
f" # Frontend (optional)\n"
|
|
2334
|
+
f" cd frontend && npm run dev\n\n"
|
|
2335
|
+
f" # Auto-notify on file save\n"
|
|
2336
|
+
f" attocode code-intel watch\n",
|
|
2337
|
+
file=sys.stderr,
|
|
2338
|
+
)
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
# ---------------------------------------------------------------------------
|
|
2342
|
+
# CLI Query Commands
|
|
2343
|
+
# ---------------------------------------------------------------------------
|
|
2344
|
+
|
|
2345
|
+
|
|
2346
|
+
def _cmd_query(args: list[str]) -> None:
|
|
2347
|
+
"""Semantic search across the codebase."""
|
|
2348
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
2349
|
+
project_dir = os.path.abspath(project_dir)
|
|
2350
|
+
|
|
2351
|
+
# Parse positional query text and flags
|
|
2352
|
+
query_parts: list[str] = []
|
|
2353
|
+
top_k = 10
|
|
2354
|
+
file_filter = ""
|
|
2355
|
+
|
|
2356
|
+
i = 0
|
|
2357
|
+
while i < len(args):
|
|
2358
|
+
arg = args[i]
|
|
2359
|
+
if arg == "--top" and i + 1 < len(args):
|
|
2360
|
+
top_k = int(args[i + 1])
|
|
2361
|
+
i += 2
|
|
2362
|
+
elif arg.startswith("--top="):
|
|
2363
|
+
top_k = int(arg.split("=", 1)[1])
|
|
2364
|
+
i += 1
|
|
2365
|
+
elif arg == "--filter" and i + 1 < len(args):
|
|
2366
|
+
file_filter = args[i + 1]
|
|
2367
|
+
i += 2
|
|
2368
|
+
elif arg.startswith("--filter="):
|
|
2369
|
+
file_filter = arg.split("=", 1)[1]
|
|
2370
|
+
i += 1
|
|
2371
|
+
elif arg == "--project" and i + 1 < len(args):
|
|
2372
|
+
i += 2 # already parsed by _parse_opts
|
|
2373
|
+
elif arg.startswith("--project="):
|
|
2374
|
+
i += 1
|
|
2375
|
+
elif not arg.startswith("-"):
|
|
2376
|
+
query_parts.append(arg)
|
|
2377
|
+
i += 1
|
|
2378
|
+
else:
|
|
2379
|
+
i += 1
|
|
2380
|
+
|
|
2381
|
+
query = " ".join(query_parts)
|
|
2382
|
+
if not query:
|
|
2383
|
+
print("Usage: attocode code-intel query <text> [--top N] [--filter '*.py'] [--project <path>]", file=sys.stderr)
|
|
2384
|
+
sys.exit(1)
|
|
2385
|
+
|
|
2386
|
+
from attocode_intel.service import CodeIntelService
|
|
2387
|
+
|
|
2388
|
+
svc = CodeIntelService(project_dir)
|
|
2389
|
+
data = svc.semantic_search_data(query, top_k=top_k, file_filter=file_filter)
|
|
2390
|
+
_print_search_results(data)
|
|
2391
|
+
|
|
2392
|
+
|
|
2393
|
+
def _cmd_symbols(args: list[str]) -> None:
|
|
2394
|
+
"""List symbols in a file or search symbols by name."""
|
|
2395
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
2396
|
+
project_dir = os.path.abspath(project_dir)
|
|
2397
|
+
|
|
2398
|
+
# Parse positional file and --search flag
|
|
2399
|
+
target_file: str | None = None
|
|
2400
|
+
search_name: str | None = None
|
|
2401
|
+
|
|
2402
|
+
i = 0
|
|
2403
|
+
while i < len(args):
|
|
2404
|
+
arg = args[i]
|
|
2405
|
+
if arg == "--search" and i + 1 < len(args):
|
|
2406
|
+
search_name = args[i + 1]
|
|
2407
|
+
i += 2
|
|
2408
|
+
elif arg.startswith("--search="):
|
|
2409
|
+
search_name = arg.split("=", 1)[1]
|
|
2410
|
+
i += 1
|
|
2411
|
+
elif arg == "--project" and i + 1 < len(args):
|
|
2412
|
+
i += 2
|
|
2413
|
+
elif arg.startswith("--project="):
|
|
2414
|
+
i += 1
|
|
2415
|
+
elif not arg.startswith("-") and target_file is None:
|
|
2416
|
+
target_file = arg
|
|
2417
|
+
i += 1
|
|
2418
|
+
else:
|
|
2419
|
+
i += 1
|
|
2420
|
+
|
|
2421
|
+
if not target_file and not search_name:
|
|
2422
|
+
print("Usage: attocode code-intel symbols <file> [--search <name>] [--project <path>]", file=sys.stderr)
|
|
2423
|
+
sys.exit(1)
|
|
2424
|
+
|
|
2425
|
+
from attocode_intel.service import CodeIntelService
|
|
2426
|
+
|
|
2427
|
+
svc = CodeIntelService(project_dir)
|
|
2428
|
+
|
|
2429
|
+
if search_name:
|
|
2430
|
+
data = svc.search_symbols_data(search_name)
|
|
2431
|
+
_print_symbols_table(data, title=f"Search results for '{search_name}'")
|
|
2432
|
+
else:
|
|
2433
|
+
assert target_file is not None
|
|
2434
|
+
data = svc.symbols_data(target_file)
|
|
2435
|
+
_print_symbols_table(data, title=f"Symbols in {target_file}")
|
|
2436
|
+
|
|
2437
|
+
|
|
2438
|
+
def _cmd_impact(args: list[str]) -> None:
|
|
2439
|
+
"""Show blast radius of file changes."""
|
|
2440
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
2441
|
+
project_dir = os.path.abspath(project_dir)
|
|
2442
|
+
|
|
2443
|
+
# Collect positional file arguments
|
|
2444
|
+
files: list[str] = []
|
|
2445
|
+
|
|
2446
|
+
i = 0
|
|
2447
|
+
while i < len(args):
|
|
2448
|
+
arg = args[i]
|
|
2449
|
+
if arg == "--project" and i + 1 < len(args):
|
|
2450
|
+
i += 2
|
|
2451
|
+
elif arg.startswith("--project="):
|
|
2452
|
+
i += 1
|
|
2453
|
+
elif not arg.startswith("-"):
|
|
2454
|
+
files.append(arg)
|
|
2455
|
+
i += 1
|
|
2456
|
+
else:
|
|
2457
|
+
i += 1
|
|
2458
|
+
|
|
2459
|
+
if not files:
|
|
2460
|
+
print("Usage: attocode code-intel impact <file> [file2 ...] [--project <path>]", file=sys.stderr)
|
|
2461
|
+
sys.exit(1)
|
|
2462
|
+
|
|
2463
|
+
from attocode_intel.service import CodeIntelService
|
|
2464
|
+
|
|
2465
|
+
svc = CodeIntelService(project_dir)
|
|
2466
|
+
data = svc.impact_analysis_data(files)
|
|
2467
|
+
_print_impact_analysis(data)
|
|
2468
|
+
|
|
2469
|
+
|
|
2470
|
+
def _cmd_hotspots(args: list[str]) -> None:
|
|
2471
|
+
"""Show risk/complexity hotspots."""
|
|
2472
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
2473
|
+
project_dir = os.path.abspath(project_dir)
|
|
2474
|
+
|
|
2475
|
+
top_n = 15
|
|
2476
|
+
|
|
2477
|
+
i = 0
|
|
2478
|
+
while i < len(args):
|
|
2479
|
+
arg = args[i]
|
|
2480
|
+
if arg == "--top" and i + 1 < len(args):
|
|
2481
|
+
top_n = int(args[i + 1])
|
|
2482
|
+
i += 2
|
|
2483
|
+
elif arg.startswith("--top="):
|
|
2484
|
+
top_n = int(arg.split("=", 1)[1])
|
|
2485
|
+
i += 1
|
|
2486
|
+
elif arg == "--project" and i + 1 < len(args):
|
|
2487
|
+
i += 2
|
|
2488
|
+
elif arg.startswith("--project="):
|
|
2489
|
+
i += 1
|
|
2490
|
+
else:
|
|
2491
|
+
i += 1
|
|
2492
|
+
|
|
2493
|
+
from attocode_intel.service import CodeIntelService
|
|
2494
|
+
|
|
2495
|
+
svc = CodeIntelService(project_dir)
|
|
2496
|
+
data = svc.hotspots_data(top_n=top_n)
|
|
2497
|
+
_print_hotspots(data)
|
|
2498
|
+
|
|
2499
|
+
|
|
2500
|
+
def _cmd_deps(args: list[str]) -> None:
|
|
2501
|
+
"""Show file dependencies and dependents."""
|
|
2502
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
2503
|
+
project_dir = os.path.abspath(project_dir)
|
|
2504
|
+
|
|
2505
|
+
target_file: str | None = None
|
|
2506
|
+
|
|
2507
|
+
i = 0
|
|
2508
|
+
while i < len(args):
|
|
2509
|
+
arg = args[i]
|
|
2510
|
+
if arg == "--project" and i + 1 < len(args):
|
|
2511
|
+
i += 2
|
|
2512
|
+
elif arg.startswith("--project="):
|
|
2513
|
+
i += 1
|
|
2514
|
+
elif not arg.startswith("-") and target_file is None:
|
|
2515
|
+
target_file = arg
|
|
2516
|
+
i += 1
|
|
2517
|
+
else:
|
|
2518
|
+
i += 1
|
|
2519
|
+
|
|
2520
|
+
if not target_file:
|
|
2521
|
+
print("Usage: attocode code-intel deps <file> [--project <path>]", file=sys.stderr)
|
|
2522
|
+
sys.exit(1)
|
|
2523
|
+
|
|
2524
|
+
from attocode_intel.service import CodeIntelService
|
|
2525
|
+
|
|
2526
|
+
svc = CodeIntelService(project_dir)
|
|
2527
|
+
data = svc.dependencies_data(target_file)
|
|
2528
|
+
_print_dependencies(data)
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
def _cmd_gc(args: list[str]) -> None:
|
|
2532
|
+
"""Run garbage collection on orphaned embeddings and unreferenced content."""
|
|
2533
|
+
import asyncio
|
|
2534
|
+
|
|
2535
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
2536
|
+
project_dir = os.path.abspath(project_dir)
|
|
2537
|
+
|
|
2538
|
+
from attocode_intel.config import CodeIntelConfig, load_remote_config
|
|
2539
|
+
|
|
2540
|
+
remote_cfg = load_remote_config(project_dir)
|
|
2541
|
+
|
|
2542
|
+
if remote_cfg.is_configured:
|
|
2543
|
+
# Remote mode: trigger GC jobs via API
|
|
2544
|
+
try:
|
|
2545
|
+
import httpx
|
|
2546
|
+
except ImportError:
|
|
2547
|
+
print("Error: httpx not installed. Install with: pip install httpx", file=sys.stderr)
|
|
2548
|
+
sys.exit(1)
|
|
2549
|
+
|
|
2550
|
+
base_url = remote_cfg.server.rstrip("/")
|
|
2551
|
+
headers = {"Authorization": f"Bearer {remote_cfg.token}"}
|
|
2552
|
+
print(f"Triggering GC on remote server {base_url}...", file=sys.stderr)
|
|
2553
|
+
|
|
2554
|
+
try:
|
|
2555
|
+
with httpx.Client(timeout=60) as client:
|
|
2556
|
+
# Trigger gc_orphaned_embeddings job
|
|
2557
|
+
resp = client.post(
|
|
2558
|
+
f"{base_url}/api/v1/jobs/enqueue",
|
|
2559
|
+
json={"function": "gc_orphaned_embeddings"},
|
|
2560
|
+
headers=headers,
|
|
2561
|
+
)
|
|
2562
|
+
if resp.status_code == 200:
|
|
2563
|
+
print(" Enqueued gc_orphaned_embeddings job")
|
|
2564
|
+
else:
|
|
2565
|
+
print(f" Warning: could not enqueue gc_orphaned_embeddings: {resp.status_code}", file=sys.stderr)
|
|
2566
|
+
|
|
2567
|
+
# Trigger gc_unreferenced_content job
|
|
2568
|
+
resp = client.post(
|
|
2569
|
+
f"{base_url}/api/v1/jobs/enqueue",
|
|
2570
|
+
json={"function": "gc_unreferenced_content"},
|
|
2571
|
+
headers=headers,
|
|
2572
|
+
)
|
|
2573
|
+
if resp.status_code == 200:
|
|
2574
|
+
print(" Enqueued gc_unreferenced_content job")
|
|
2575
|
+
else:
|
|
2576
|
+
print(f" Warning: could not enqueue gc_unreferenced_content: {resp.status_code}", file=sys.stderr)
|
|
2577
|
+
except httpx.HTTPError as exc:
|
|
2578
|
+
print(f"Error contacting remote server: {exc}", file=sys.stderr)
|
|
2579
|
+
sys.exit(1)
|
|
2580
|
+
|
|
2581
|
+
print("GC jobs enqueued on remote server.")
|
|
2582
|
+
return
|
|
2583
|
+
|
|
2584
|
+
# Local mode: run GC directly against the database
|
|
2585
|
+
config = CodeIntelConfig.from_env()
|
|
2586
|
+
if not config.database_url:
|
|
2587
|
+
# Pure local mode (SQLite / no DB) — clear AST cache
|
|
2588
|
+
print("Local mode: clearing AST cache...", file=sys.stderr)
|
|
2589
|
+
from pathlib import Path
|
|
2590
|
+
|
|
2591
|
+
cache_dir = Path(project_dir) / ".attocode" / "cache"
|
|
2592
|
+
if cache_dir.exists():
|
|
2593
|
+
removed = 0
|
|
2594
|
+
for f in cache_dir.iterdir():
|
|
2595
|
+
if f.is_file():
|
|
2596
|
+
f.unlink()
|
|
2597
|
+
removed += 1
|
|
2598
|
+
print(f" Removed {removed} cached file(s).")
|
|
2599
|
+
else:
|
|
2600
|
+
print(" No cache directory found.")
|
|
2601
|
+
print("GC complete.")
|
|
2602
|
+
return
|
|
2603
|
+
|
|
2604
|
+
# Service mode with database: run GC operations
|
|
2605
|
+
async def _run_gc() -> None:
|
|
2606
|
+
from attocode_intel.db.engine import get_session
|
|
2607
|
+
from attocode_intel.storage.content_store import ContentStore
|
|
2608
|
+
from attocode_intel.storage.embedding_store import EmbeddingStore
|
|
2609
|
+
|
|
2610
|
+
print("Running garbage collection...", file=sys.stderr)
|
|
2611
|
+
|
|
2612
|
+
async for session in get_session():
|
|
2613
|
+
# 1. GC orphaned embeddings
|
|
2614
|
+
emb_store = EmbeddingStore(session)
|
|
2615
|
+
emb_count = await emb_store.gc_orphaned(
|
|
2616
|
+
min_age_minutes=config.gc_content_min_age_minutes,
|
|
2617
|
+
)
|
|
2618
|
+
print(f" Orphaned embeddings removed: {emb_count}")
|
|
2619
|
+
|
|
2620
|
+
# 2. GC unreferenced content
|
|
2621
|
+
content_store = ContentStore(session)
|
|
2622
|
+
content_count = await content_store.gc_unreferenced(
|
|
2623
|
+
min_age_minutes=config.gc_content_min_age_minutes,
|
|
2624
|
+
)
|
|
2625
|
+
print(f" Unreferenced content removed: {content_count}")
|
|
2626
|
+
|
|
2627
|
+
await session.commit()
|
|
2628
|
+
print("GC complete.")
|
|
2629
|
+
return
|
|
2630
|
+
|
|
2631
|
+
asyncio.run(_run_gc())
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
def _cmd_verify(args: list[str]) -> None:
|
|
2635
|
+
"""Run integrity checks on the code-intel index."""
|
|
2636
|
+
import asyncio
|
|
2637
|
+
|
|
2638
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
2639
|
+
project_dir = os.path.abspath(project_dir)
|
|
2640
|
+
|
|
2641
|
+
from attocode_intel.config import CodeIntelConfig
|
|
2642
|
+
|
|
2643
|
+
config = CodeIntelConfig.from_env()
|
|
2644
|
+
|
|
2645
|
+
def _run_local_verify() -> None:
|
|
2646
|
+
# Pure local mode — verify against the real DB-backed local index layout.
|
|
2647
|
+
from pathlib import Path
|
|
2648
|
+
|
|
2649
|
+
from attocode_intel.service import CodeIntelService
|
|
2650
|
+
from attocode_intel._internal.integrations.context.ast_service import ASTService
|
|
2651
|
+
|
|
2652
|
+
print("Running local integrity checks...", file=sys.stderr)
|
|
2653
|
+
|
|
2654
|
+
os.environ.setdefault("ATTOCODE_PROJECT_DIR", project_dir)
|
|
2655
|
+
|
|
2656
|
+
cache_dir = Path(project_dir) / ".attocode" / "cache"
|
|
2657
|
+
index_dir = Path(project_dir) / ".attocode" / "index"
|
|
2658
|
+
symbols_db = index_dir / "symbols.db"
|
|
2659
|
+
vectors_dir = Path(project_dir) / ".attocode" / "vectors"
|
|
2660
|
+
|
|
2661
|
+
issues: list[str] = []
|
|
2662
|
+
health: dict[str, object] = {}
|
|
2663
|
+
hydration: dict[str, object] = {}
|
|
2664
|
+
|
|
2665
|
+
CodeIntelService._reset_instances()
|
|
2666
|
+
ASTService.clear_instances()
|
|
2667
|
+
try:
|
|
2668
|
+
svc = CodeIntelService.get_instance(project_dir)
|
|
2669
|
+
health = svc._health_snapshot()
|
|
2670
|
+
hydration = svc.hydration_status()
|
|
2671
|
+
except Exception as exc:
|
|
2672
|
+
issues.append(f"Health check failed ({type(exc).__name__}: {exc})")
|
|
2673
|
+
|
|
2674
|
+
if not cache_dir.exists():
|
|
2675
|
+
issues.append("Cache directory missing (.attocode/cache)")
|
|
2676
|
+
if not index_dir.exists():
|
|
2677
|
+
issues.append("Index directory missing (.attocode/index)")
|
|
2678
|
+
if index_dir.exists() and not symbols_db.exists():
|
|
2679
|
+
issues.append("Index database missing (.attocode/index/symbols.db)")
|
|
2680
|
+
|
|
2681
|
+
discovery_count = int(health.get("discovery_count", 0) or 0)
|
|
2682
|
+
indexed_files = int(health.get("ast_indexed_files", 0) or 0)
|
|
2683
|
+
if health and discovery_count == 0:
|
|
2684
|
+
issues.append("No files discovered by CodeIntelService")
|
|
2685
|
+
if health and indexed_files == 0:
|
|
2686
|
+
issues.append("No files indexed by ASTService")
|
|
2687
|
+
|
|
2688
|
+
if issues:
|
|
2689
|
+
print(f"\nFound {len(issues)} issue(s):")
|
|
2690
|
+
for issue in issues:
|
|
2691
|
+
print(f" [!] {issue}")
|
|
2692
|
+
print("\nRun 'attocode code-intel reindex' to rebuild the index.")
|
|
2693
|
+
else:
|
|
2694
|
+
cache_files = list(cache_dir.iterdir()) if cache_dir.exists() else []
|
|
2695
|
+
print(f"\nLocal index OK:")
|
|
2696
|
+
print(f" Health status: {health.get('health_status', 'unknown')}")
|
|
2697
|
+
print(f" Discovery count: {discovery_count}")
|
|
2698
|
+
print(f" Indexed files: {indexed_files}")
|
|
2699
|
+
print(f" Cache files: {len(cache_files)}")
|
|
2700
|
+
print(f" Index DB: {symbols_db}")
|
|
2701
|
+
print(f" Vectors dir: {vectors_dir} ({'present' if vectors_dir.exists() else 'missing'})")
|
|
2702
|
+
if hydration:
|
|
2703
|
+
print(
|
|
2704
|
+
" Hydration: "
|
|
2705
|
+
f"{hydration.get('tier', 'unknown')} / {hydration.get('phase', 'unknown')}"
|
|
2706
|
+
)
|
|
2707
|
+
return
|
|
2708
|
+
|
|
2709
|
+
if not config.database_url:
|
|
2710
|
+
_run_local_verify()
|
|
2711
|
+
return
|
|
2712
|
+
|
|
2713
|
+
# Service mode: run integrity checks against the database
|
|
2714
|
+
async def _run_verify() -> None:
|
|
2715
|
+
from sqlalchemy import text
|
|
2716
|
+
|
|
2717
|
+
from attocode_intel.db.engine import get_session
|
|
2718
|
+
|
|
2719
|
+
print("Running integrity checks...\n", file=sys.stderr)
|
|
2720
|
+
issues: list[str] = []
|
|
2721
|
+
stats: dict[str, int] = {}
|
|
2722
|
+
|
|
2723
|
+
async for session in get_session():
|
|
2724
|
+
# 1. Orphaned BranchFile entries (content_sha not in file_contents)
|
|
2725
|
+
result = await session.execute(text("""
|
|
2726
|
+
SELECT COUNT(*) FROM branch_files
|
|
2727
|
+
WHERE content_sha IS NOT NULL
|
|
2728
|
+
AND content_sha NOT IN (SELECT sha256 FROM file_contents)
|
|
2729
|
+
"""))
|
|
2730
|
+
orphaned_bf = result.scalar() or 0
|
|
2731
|
+
stats["orphaned_branch_files"] = orphaned_bf
|
|
2732
|
+
if orphaned_bf > 0:
|
|
2733
|
+
issues.append(f"{orphaned_bf} branch_files reference missing file_contents")
|
|
2734
|
+
|
|
2735
|
+
# 2. Orphaned embeddings (content_sha not in any branch manifest)
|
|
2736
|
+
result = await session.execute(text("""
|
|
2737
|
+
SELECT COUNT(*) FROM embeddings
|
|
2738
|
+
WHERE content_sha NOT IN (
|
|
2739
|
+
SELECT DISTINCT content_sha FROM branch_files WHERE content_sha IS NOT NULL
|
|
2740
|
+
)
|
|
2741
|
+
"""))
|
|
2742
|
+
orphaned_emb = result.scalar() or 0
|
|
2743
|
+
stats["orphaned_embeddings"] = orphaned_emb
|
|
2744
|
+
if orphaned_emb > 0:
|
|
2745
|
+
issues.append(f"{orphaned_emb} embeddings reference content not in any branch manifest")
|
|
2746
|
+
|
|
2747
|
+
# 3. Missing embeddings (content_sha in branch manifest but no embedding)
|
|
2748
|
+
result = await session.execute(text("""
|
|
2749
|
+
SELECT COUNT(DISTINCT bf.content_sha) FROM branch_files bf
|
|
2750
|
+
WHERE bf.content_sha IS NOT NULL
|
|
2751
|
+
AND bf.content_sha NOT IN (SELECT DISTINCT content_sha FROM embeddings)
|
|
2752
|
+
"""))
|
|
2753
|
+
missing_emb = result.scalar() or 0
|
|
2754
|
+
stats["missing_embeddings"] = missing_emb
|
|
2755
|
+
if missing_emb > 0:
|
|
2756
|
+
issues.append(f"{missing_emb} content SHAs in branch manifests have no embeddings")
|
|
2757
|
+
|
|
2758
|
+
# 4. Broken parent_branch_id references
|
|
2759
|
+
result = await session.execute(text("""
|
|
2760
|
+
SELECT COUNT(*) FROM branches
|
|
2761
|
+
WHERE parent_branch_id IS NOT NULL
|
|
2762
|
+
AND parent_branch_id NOT IN (SELECT id FROM branches)
|
|
2763
|
+
"""))
|
|
2764
|
+
broken_parents = result.scalar() or 0
|
|
2765
|
+
stats["broken_parent_branch_refs"] = broken_parents
|
|
2766
|
+
if broken_parents > 0:
|
|
2767
|
+
issues.append(f"{broken_parents} branches reference non-existent parent branches")
|
|
2768
|
+
|
|
2769
|
+
# 5. Orphaned symbols (content_sha not in file_contents)
|
|
2770
|
+
result = await session.execute(text("""
|
|
2771
|
+
SELECT COUNT(*) FROM symbols
|
|
2772
|
+
WHERE content_sha NOT IN (SELECT sha256 FROM file_contents)
|
|
2773
|
+
"""))
|
|
2774
|
+
orphaned_sym = result.scalar() or 0
|
|
2775
|
+
stats["orphaned_symbols"] = orphaned_sym
|
|
2776
|
+
if orphaned_sym > 0:
|
|
2777
|
+
issues.append(f"{orphaned_sym} symbols reference missing file_contents")
|
|
2778
|
+
|
|
2779
|
+
# Print results
|
|
2780
|
+
print("Integrity check results:")
|
|
2781
|
+
print(f" Orphaned branch_files: {stats['orphaned_branch_files']}")
|
|
2782
|
+
print(f" Orphaned embeddings: {stats['orphaned_embeddings']}")
|
|
2783
|
+
print(f" Missing embeddings: {stats['missing_embeddings']}")
|
|
2784
|
+
print(f" Broken parent_branch refs: {stats['broken_parent_branch_refs']}")
|
|
2785
|
+
print(f" Orphaned symbols: {stats['orphaned_symbols']}")
|
|
2786
|
+
|
|
2787
|
+
if issues:
|
|
2788
|
+
print(f"\nFound {len(issues)} issue(s):")
|
|
2789
|
+
for issue in issues:
|
|
2790
|
+
print(f" [!] {issue}")
|
|
2791
|
+
print("\nRun 'attocode code-intel gc' to clean up orphaned data.")
|
|
2792
|
+
print("Run 'attocode code-intel reindex' to rebuild missing embeddings.")
|
|
2793
|
+
else:
|
|
2794
|
+
print("\nAll checks passed.")
|
|
2795
|
+
|
|
2796
|
+
return
|
|
2797
|
+
|
|
2798
|
+
try:
|
|
2799
|
+
asyncio.run(_run_verify())
|
|
2800
|
+
except RuntimeError as exc:
|
|
2801
|
+
if "Database not initialized" not in str(exc):
|
|
2802
|
+
raise
|
|
2803
|
+
print(
|
|
2804
|
+
"Database engine is not initialized; falling back to local integrity checks.",
|
|
2805
|
+
file=sys.stderr,
|
|
2806
|
+
)
|
|
2807
|
+
_run_local_verify()
|
|
2808
|
+
|
|
2809
|
+
|
|
2810
|
+
def _cmd_reindex(args: list[str]) -> None:
|
|
2811
|
+
"""Force a full reindex of the project."""
|
|
2812
|
+
_, project_dir, _, _ = _parse_opts(args)
|
|
2813
|
+
project_dir = os.path.abspath(project_dir)
|
|
2814
|
+
|
|
2815
|
+
from attocode_intel.config import load_remote_config
|
|
2816
|
+
|
|
2817
|
+
remote_cfg = load_remote_config(project_dir)
|
|
2818
|
+
|
|
2819
|
+
if remote_cfg.is_configured:
|
|
2820
|
+
# Remote mode: trigger index_repository job via API
|
|
2821
|
+
try:
|
|
2822
|
+
import httpx
|
|
2823
|
+
except ImportError:
|
|
2824
|
+
print("Error: httpx not installed. Install with: pip install httpx", file=sys.stderr)
|
|
2825
|
+
sys.exit(1)
|
|
2826
|
+
|
|
2827
|
+
base_url = remote_cfg.server.rstrip("/")
|
|
2828
|
+
headers = {"Authorization": f"Bearer {remote_cfg.token}"}
|
|
2829
|
+
repo_id = remote_cfg.repo_id
|
|
2830
|
+
if not repo_id:
|
|
2831
|
+
print("Error: no repo_id configured. Run 'attocode code-intel connect' first.", file=sys.stderr)
|
|
2832
|
+
sys.exit(1)
|
|
2833
|
+
|
|
2834
|
+
print(f"Triggering full reindex on remote server {base_url}...", file=sys.stderr)
|
|
2835
|
+
|
|
2836
|
+
try:
|
|
2837
|
+
with httpx.Client(timeout=60) as client:
|
|
2838
|
+
resp = client.post(
|
|
2839
|
+
f"{base_url}/api/v1/repos/{repo_id}/index",
|
|
2840
|
+
headers=headers,
|
|
2841
|
+
)
|
|
2842
|
+
if resp.status_code in (200, 201, 202):
|
|
2843
|
+
data = resp.json()
|
|
2844
|
+
print(f" Reindex job enqueued: {data}")
|
|
2845
|
+
else:
|
|
2846
|
+
print(f" Error: server returned {resp.status_code}: {resp.text}", file=sys.stderr)
|
|
2847
|
+
sys.exit(1)
|
|
2848
|
+
except httpx.HTTPError as exc:
|
|
2849
|
+
print(f"Error contacting remote server: {exc}", file=sys.stderr)
|
|
2850
|
+
sys.exit(1)
|
|
2851
|
+
|
|
2852
|
+
print("Reindex triggered on remote server.")
|
|
2853
|
+
return
|
|
2854
|
+
|
|
2855
|
+
# Local mode: clear cache and reinitialize
|
|
2856
|
+
from pathlib import Path
|
|
2857
|
+
|
|
2858
|
+
print(f"Reindexing {project_dir}...", file=sys.stderr)
|
|
2859
|
+
|
|
2860
|
+
# 1. Clear AST cache
|
|
2861
|
+
cache_dir = Path(project_dir) / ".attocode" / "cache"
|
|
2862
|
+
if cache_dir.exists():
|
|
2863
|
+
removed = 0
|
|
2864
|
+
for f in cache_dir.iterdir():
|
|
2865
|
+
if f.is_file():
|
|
2866
|
+
f.unlink()
|
|
2867
|
+
removed += 1
|
|
2868
|
+
print(f" Cleared {removed} cached file(s).")
|
|
2869
|
+
|
|
2870
|
+
# 2. Remove stale index file to force full rebuild
|
|
2871
|
+
index_file = Path(project_dir) / ".attocode" / "index.json"
|
|
2872
|
+
if index_file.exists():
|
|
2873
|
+
index_file.unlink()
|
|
2874
|
+
print(" Removed stale index file.")
|
|
2875
|
+
|
|
2876
|
+
# 3. Run full index
|
|
2877
|
+
os.environ.setdefault("ATTOCODE_PROJECT_DIR", project_dir)
|
|
2878
|
+
|
|
2879
|
+
from attocode_intel._internal.integrations.context.semantic_search import SemanticSearchManager
|
|
2880
|
+
|
|
2881
|
+
mgr = SemanticSearchManager(root_dir=project_dir)
|
|
2882
|
+
|
|
2883
|
+
if not mgr.is_available:
|
|
2884
|
+
print(
|
|
2885
|
+
"No embedding provider available. Install the semantic extra:\n"
|
|
2886
|
+
" uv tool install 'attocode-code-intel[semantic]'",
|
|
2887
|
+
file=sys.stderr,
|
|
2888
|
+
)
|
|
2889
|
+
mgr.close()
|
|
2890
|
+
sys.exit(1)
|
|
2891
|
+
|
|
2892
|
+
count = mgr.index()
|
|
2893
|
+
print(f" Indexed {count} chunks.")
|
|
2894
|
+
mgr.close()
|
|
2895
|
+
print("Reindex complete.")
|
|
2896
|
+
|
|
2897
|
+
|
|
2898
|
+
# ---------------------------------------------------------------------------
|
|
2899
|
+
# Terminal formatters for query commands
|
|
2900
|
+
# ---------------------------------------------------------------------------
|
|
2901
|
+
|
|
2902
|
+
|
|
2903
|
+
def _print_search_results(data: dict) -> None:
|
|
2904
|
+
"""Print semantic search results as a numbered list with file, score, snippet."""
|
|
2905
|
+
results = data.get("results", [])
|
|
2906
|
+
total = data.get("total", len(results))
|
|
2907
|
+
query = data.get("query", "")
|
|
2908
|
+
|
|
2909
|
+
print(f"Semantic search: \"{query}\" ({total} result(s))\n")
|
|
2910
|
+
|
|
2911
|
+
if not results:
|
|
2912
|
+
print(" No results found.")
|
|
2913
|
+
return
|
|
2914
|
+
|
|
2915
|
+
for idx, r in enumerate(results, 1):
|
|
2916
|
+
file_path = r.get("file_path", "?")
|
|
2917
|
+
score = r.get("score", 0.0)
|
|
2918
|
+
snippet = r.get("snippet", "")
|
|
2919
|
+
line = r.get("line")
|
|
2920
|
+
|
|
2921
|
+
loc = f"{file_path}:{line}" if line else file_path
|
|
2922
|
+
print(f" {idx:>3}. {loc} (score: {score:.4f})")
|
|
2923
|
+
|
|
2924
|
+
if snippet:
|
|
2925
|
+
# Indent snippet lines, truncate long ones
|
|
2926
|
+
for sline in snippet.strip().splitlines()[:3]:
|
|
2927
|
+
truncated = sline[:120] + "..." if len(sline) > 120 else sline
|
|
2928
|
+
print(f" {truncated}")
|
|
2929
|
+
print()
|
|
2930
|
+
|
|
2931
|
+
|
|
2932
|
+
def _print_symbols_table(data: list[dict], title: str = "Symbols") -> None:
|
|
2933
|
+
"""Print symbols as a fixed-width table with name, kind, file, line."""
|
|
2934
|
+
print(f"{title} ({len(data)} symbol(s))\n")
|
|
2935
|
+
|
|
2936
|
+
if not data:
|
|
2937
|
+
print(" No symbols found.")
|
|
2938
|
+
return
|
|
2939
|
+
|
|
2940
|
+
# Compute column widths
|
|
2941
|
+
name_w = max(len(s.get("name", "")) for s in data)
|
|
2942
|
+
name_w = max(name_w, 4) # minimum "Name"
|
|
2943
|
+
name_w = min(name_w, 40) # cap width
|
|
2944
|
+
|
|
2945
|
+
kind_w = max(len(s.get("kind", "")) for s in data)
|
|
2946
|
+
kind_w = max(kind_w, 4)
|
|
2947
|
+
kind_w = min(kind_w, 15)
|
|
2948
|
+
|
|
2949
|
+
# Header
|
|
2950
|
+
header = f" {'Name':<{name_w}} {'Kind':<{kind_w}} {'File':<50} {'Line':>5}"
|
|
2951
|
+
print(header)
|
|
2952
|
+
print(f" {'-' * name_w} {'-' * kind_w} {'-' * 50} {'-' * 5}")
|
|
2953
|
+
|
|
2954
|
+
for s in data:
|
|
2955
|
+
name = s.get("name", "")[:name_w]
|
|
2956
|
+
kind = s.get("kind", "")[:kind_w]
|
|
2957
|
+
file_path = s.get("file_path", "")
|
|
2958
|
+
if len(file_path) > 50:
|
|
2959
|
+
file_path = "..." + file_path[-47:]
|
|
2960
|
+
start_line = s.get("start_line", "")
|
|
2961
|
+
|
|
2962
|
+
print(f" {name:<{name_w}} {kind:<{kind_w}} {file_path:<50} {start_line:>5}")
|
|
2963
|
+
|
|
2964
|
+
|
|
2965
|
+
def _print_impact_analysis(data: dict) -> None:
|
|
2966
|
+
"""Print impact analysis as a tree of affected files."""
|
|
2967
|
+
changed = data.get("changed_files", [])
|
|
2968
|
+
impacted = data.get("impacted_files", [])
|
|
2969
|
+
total = data.get("total_impacted", len(impacted))
|
|
2970
|
+
layers = data.get("layers", [])
|
|
2971
|
+
|
|
2972
|
+
print(f"Impact analysis: {len(changed)} changed file(s), {total} impacted file(s)\n")
|
|
2973
|
+
|
|
2974
|
+
print(" Changed files:")
|
|
2975
|
+
for f in changed:
|
|
2976
|
+
print(f" * {f}")
|
|
2977
|
+
|
|
2978
|
+
if not layers:
|
|
2979
|
+
print("\n No downstream impact detected.")
|
|
2980
|
+
return
|
|
2981
|
+
|
|
2982
|
+
print()
|
|
2983
|
+
for layer in layers:
|
|
2984
|
+
depth = layer.get("depth", "?")
|
|
2985
|
+
files = layer.get("files", [])
|
|
2986
|
+
print(f" Depth {depth} ({len(files)} file(s)):")
|
|
2987
|
+
for f in files:
|
|
2988
|
+
print(f" {'|' * depth} {f}")
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
def _print_hotspots(data: dict) -> None:
|
|
2992
|
+
"""Print hotspots as a ranked table with file, categories, score."""
|
|
2993
|
+
file_hotspots = data.get("file_hotspots", [])
|
|
2994
|
+
fn_hotspots = data.get("function_hotspots", [])
|
|
2995
|
+
orphans = data.get("orphan_files", [])
|
|
2996
|
+
|
|
2997
|
+
# File hotspots
|
|
2998
|
+
print(f"File hotspots ({len(file_hotspots)} file(s))\n")
|
|
2999
|
+
|
|
3000
|
+
if file_hotspots:
|
|
3001
|
+
header = f" {'#':>3} {'File':<55} {'Lines':>5} {'Score':>6} Categories"
|
|
3002
|
+
print(header)
|
|
3003
|
+
print(f" {'-' * 3} {'-' * 55} {'-' * 5} {'-' * 6} {'-' * 25}")
|
|
3004
|
+
|
|
3005
|
+
for idx, h in enumerate(file_hotspots, 1):
|
|
3006
|
+
path = h.get("path", "")
|
|
3007
|
+
if len(path) > 55:
|
|
3008
|
+
path = "..." + path[-52:]
|
|
3009
|
+
lines = h.get("line_count", 0)
|
|
3010
|
+
score = h.get("composite", 0.0)
|
|
3011
|
+
cats = ", ".join(h.get("categories", []))
|
|
3012
|
+
|
|
3013
|
+
print(f" {idx:>3} {path:<55} {lines:>5} {score:>6.2f} {cats}")
|
|
3014
|
+
else:
|
|
3015
|
+
print(" No file hotspots found.")
|
|
3016
|
+
|
|
3017
|
+
# Function hotspots
|
|
3018
|
+
if fn_hotspots:
|
|
3019
|
+
print(f"\nFunction hotspots ({len(fn_hotspots)} function(s))\n")
|
|
3020
|
+
header = f" {'Name':<35} {'File':<40} {'Lines':>5} {'Params':>6}"
|
|
3021
|
+
print(header)
|
|
3022
|
+
print(f" {'-' * 35} {'-' * 40} {'-' * 5} {'-' * 6}")
|
|
3023
|
+
|
|
3024
|
+
for fh in fn_hotspots:
|
|
3025
|
+
name = fh.get("name", "")[:35]
|
|
3026
|
+
fp = fh.get("file_path", "")
|
|
3027
|
+
if len(fp) > 40:
|
|
3028
|
+
fp = "..." + fp[-37:]
|
|
3029
|
+
lc = fh.get("line_count", 0)
|
|
3030
|
+
pc = fh.get("param_count", 0)
|
|
3031
|
+
print(f" {name:<35} {fp:<40} {lc:>5} {pc:>6}")
|
|
3032
|
+
|
|
3033
|
+
# Orphan files
|
|
3034
|
+
if orphans:
|
|
3035
|
+
print(f"\nOrphan files ({len(orphans)} file(s) — no imports/importers)\n")
|
|
3036
|
+
for o in orphans:
|
|
3037
|
+
path = o.get("path", "")
|
|
3038
|
+
lines = o.get("line_count", 0)
|
|
3039
|
+
print(f" {path} ({lines} lines)")
|
|
3040
|
+
|
|
3041
|
+
|
|
3042
|
+
def _print_dependencies(data: dict) -> None:
|
|
3043
|
+
"""Print dependency information: imports and imported-by lists."""
|
|
3044
|
+
path = data.get("path", "?")
|
|
3045
|
+
imports = data.get("imports", [])
|
|
3046
|
+
imported_by = data.get("imported_by", [])
|
|
3047
|
+
|
|
3048
|
+
print(f"Dependencies for {path}\n")
|
|
3049
|
+
|
|
3050
|
+
print(f" Imports ({len(imports)}):")
|
|
3051
|
+
if imports:
|
|
3052
|
+
for dep in imports:
|
|
3053
|
+
print(f" -> {dep}")
|
|
3054
|
+
else:
|
|
3055
|
+
print(" (none)")
|
|
3056
|
+
|
|
3057
|
+
print(f"\n Imported by ({len(imported_by)}):")
|
|
3058
|
+
if imported_by:
|
|
3059
|
+
for dep in imported_by:
|
|
3060
|
+
print(f" <- {dep}")
|
|
3061
|
+
else:
|
|
3062
|
+
print(" (none)")
|