codeply-cli 0.1.1 → 0.3.0
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.
- package/THIRD_PARTY_NOTICES.md +63 -0
- package/bin/codeply.js +562 -259
- package/lib/agent.mjs +1721 -116
- package/lib/ai.js +1107 -184
- package/lib/apply-limit.js +33 -41
- package/lib/apply-patch.js +217 -0
- package/lib/arity-table.json +138 -0
- package/lib/arity.js +70 -0
- package/lib/auth.js +118 -112
- package/lib/commands.js +103 -0
- package/lib/config.js +392 -98
- package/lib/design-library/artifact-template.html +500 -0
- package/lib/design-library/assemble-artifact.mjs +21 -0
- package/lib/design-library/build.mjs +226 -0
- package/lib/design-library/cleanup.mjs +45 -0
- package/lib/design-library/index.json +22380 -0
- package/lib/design-library/make-table-data.mjs +39 -0
- package/lib/design-library/query.mjs +104 -0
- package/lib/diagnostics.js +192 -0
- package/lib/edit-engine.js +171 -14
- package/lib/github-agent.mjs +290 -0
- package/lib/history.js +75 -0
- package/lib/mcp.js +258 -0
- package/lib/native-tools.mjs +189 -0
- package/lib/oauth-connectors.js +478 -0
- package/lib/permissions.js +109 -0
- package/lib/plugins.js +353 -0
- package/lib/render-md.mjs +617 -617
- package/lib/server.mjs +457 -0
- package/lib/session-db.js +220 -0
- package/lib/share.js +168 -0
- package/lib/skills.js +416 -0
- package/lib/snapshot.js +146 -0
- package/lib/subagents.js +164 -0
- package/lib/terminal-hints.js +154 -0
- package/lib/textarea.mjs +258 -258
- package/lib/tools.mjs +1709 -73
- package/lib/tui.js +7 -7
- package/lib/tui.mjs +1086 -899
- package/lib/typecheck-worker.js +147 -0
- package/lib/web-tools.js +158 -0
- package/package.json +5 -3
- package/skills/SOURCE.md +58 -0
- package/skills/accessibility/SKILL.md +147 -0
- package/skills/agent-architecture-audit/SKILL.md +257 -0
- package/skills/agent-eval/SKILL.md +147 -0
- package/skills/agent-harness-construction/SKILL.md +74 -0
- package/skills/agent-introspection-debugging/SKILL.md +154 -0
- package/skills/agent-payment-x402/SKILL.md +225 -0
- package/skills/agent-self-evaluation/SKILL.md +182 -0
- package/skills/agent-self-evaluation/references/evaluation-criteria.md +71 -0
- package/skills/agent-self-evaluation/references/hook-integration.md +64 -0
- package/skills/agent-sort/SKILL.md +216 -0
- package/skills/agentic-engineering/SKILL.md +64 -0
- package/skills/agentic-os/SKILL.md +388 -0
- package/skills/ai-first-engineering/SKILL.md +52 -0
- package/skills/ai-regression-testing/SKILL.md +386 -0
- package/skills/android-clean-architecture/SKILL.md +340 -0
- package/skills/angular-developer/SKILL.md +155 -0
- package/skills/angular-developer/references/angular-animations.md +160 -0
- package/skills/angular-developer/references/angular-aria.md +410 -0
- package/skills/angular-developer/references/cli.md +86 -0
- package/skills/angular-developer/references/component-harnesses.md +59 -0
- package/skills/angular-developer/references/component-styling.md +91 -0
- package/skills/angular-developer/references/components.md +117 -0
- package/skills/angular-developer/references/creating-services.md +97 -0
- package/skills/angular-developer/references/data-resolvers.md +69 -0
- package/skills/angular-developer/references/define-routes.md +67 -0
- package/skills/angular-developer/references/defining-providers.md +72 -0
- package/skills/angular-developer/references/di-fundamentals.md +120 -0
- package/skills/angular-developer/references/e2e-testing.md +56 -0
- package/skills/angular-developer/references/effects.md +83 -0
- package/skills/angular-developer/references/hierarchical-injectors.md +43 -0
- package/skills/angular-developer/references/host-elements.md +80 -0
- package/skills/angular-developer/references/injection-context.md +63 -0
- package/skills/angular-developer/references/inputs.md +101 -0
- package/skills/angular-developer/references/linked-signal.md +59 -0
- package/skills/angular-developer/references/loading-strategies.md +61 -0
- package/skills/angular-developer/references/mcp.md +108 -0
- package/skills/angular-developer/references/navigate-to-routes.md +69 -0
- package/skills/angular-developer/references/outputs.md +86 -0
- package/skills/angular-developer/references/reactive-forms.md +122 -0
- package/skills/angular-developer/references/rendering-strategies.md +44 -0
- package/skills/angular-developer/references/resource.md +77 -0
- package/skills/angular-developer/references/route-animations.md +56 -0
- package/skills/angular-developer/references/route-guards.md +52 -0
- package/skills/angular-developer/references/router-lifecycle.md +45 -0
- package/skills/angular-developer/references/router-testing.md +87 -0
- package/skills/angular-developer/references/show-routes-with-outlets.md +68 -0
- package/skills/angular-developer/references/signal-forms.md +795 -0
- package/skills/angular-developer/references/signals-overview.md +94 -0
- package/skills/angular-developer/references/tailwind-css.md +69 -0
- package/skills/angular-developer/references/template-driven-forms.md +114 -0
- package/skills/angular-developer/references/testing-fundamentals.md +65 -0
- package/skills/api-connector-builder/SKILL.md +121 -0
- package/skills/api-design/SKILL.md +524 -0
- package/skills/architecture-decision-records/SKILL.md +180 -0
- package/skills/article-writing/SKILL.md +80 -0
- package/skills/automation-audit-ops/SKILL.md +143 -0
- package/skills/autonomous-agent-harness/SKILL.md +274 -0
- package/skills/autonomous-loops/SKILL.md +611 -0
- package/skills/backend-patterns/SKILL.md +562 -0
- package/skills/benchmark/SKILL.md +95 -0
- package/skills/benchmark-methodology/SKILL.md +191 -0
- package/skills/benchmark-optimization-loop/SKILL.md +71 -0
- package/skills/blender-motion-state-inspection/SKILL.md +165 -0
- package/skills/blueprint/SKILL.md +106 -0
- package/skills/brand-discovery/SKILL.md +145 -0
- package/skills/brand-discovery/references/10_purpose-why.md +40 -0
- package/skills/brand-discovery/references/20_positioning.md +44 -0
- package/skills/brand-discovery/references/30_audience-niche.md +52 -0
- package/skills/brand-discovery/references/40_personality-archetype.md +57 -0
- package/skills/brand-discovery/references/50_voice-tone.md +59 -0
- package/skills/brand-discovery/references/60_narrative-story.md +50 -0
- package/skills/brand-discovery/references/70_founder-tension.md +49 -0
- package/skills/brand-discovery/references/90_SYNTHESIS.md +133 -0
- package/skills/brand-voice/SKILL.md +98 -0
- package/skills/brand-voice/references/voice-profile-schema.md +55 -0
- package/skills/browser-qa/SKILL.md +105 -0
- package/skills/bun-runtime/SKILL.md +85 -0
- package/skills/canary-watch/SKILL.md +108 -0
- package/skills/carrier-relationship-management/SKILL.md +212 -0
- package/skills/cisco-ios-patterns/SKILL.md +164 -0
- package/skills/ck/SKILL.md +148 -0
- package/skills/claude-devfleet/SKILL.md +112 -0
- package/skills/click-path-audit/SKILL.md +245 -0
- package/skills/clickhouse-io/SKILL.md +445 -0
- package/skills/code-tour/SKILL.md +254 -0
- package/skills/codebase-onboarding/SKILL.md +234 -0
- package/skills/codehealth-mcp/SKILL.md +167 -0
- package/skills/coding-standards/SKILL.md +551 -0
- package/skills/competitive-platform-analysis/SKILL.md +214 -0
- package/skills/competitive-report-structure/SKILL.md +162 -0
- package/skills/compose-multiplatform-patterns/SKILL.md +300 -0
- package/skills/config-gc/SKILL.md +120 -0
- package/skills/configure-ecc/SKILL.md +385 -0
- package/skills/connections-optimizer/SKILL.md +190 -0
- package/skills/content-engine/SKILL.md +132 -0
- package/skills/content-hash-cache-pattern/SKILL.md +162 -0
- package/skills/context-budget/SKILL.md +136 -0
- package/skills/continuous-agent-loop/SKILL.md +46 -0
- package/skills/continuous-learning/SKILL.md +132 -0
- package/skills/continuous-learning-v2/SKILL.md +361 -0
- package/skills/contract-first/SKILL.md +287 -0
- package/skills/cost-aware-llm-pipeline/SKILL.md +184 -0
- package/skills/cost-tracking/SKILL.md +97 -0
- package/skills/council/SKILL.md +204 -0
- package/skills/cpp-coding-standards/SKILL.md +724 -0
- package/skills/cpp-testing/SKILL.md +325 -0
- package/skills/crosspost/SKILL.md +112 -0
- package/skills/csharp-testing/SKILL.md +322 -0
- package/skills/customer-billing-ops/SKILL.md +141 -0
- package/skills/customs-trade-compliance/SKILL.md +263 -0
- package/skills/dart-flutter-patterns/SKILL.md +564 -0
- package/skills/dashboard-builder/SKILL.md +109 -0
- package/skills/data-scraper-agent/SKILL.md +765 -0
- package/skills/data-throughput-accelerator/SKILL.md +74 -0
- package/skills/database-migrations/SKILL.md +430 -0
- package/skills/deep-research/SKILL.md +160 -0
- package/skills/defi-amm-security/SKILL.md +167 -0
- package/skills/delivery-gate/SKILL.md +126 -0
- package/skills/deployment-patterns/SKILL.md +428 -0
- package/skills/design-system/SKILL.md +83 -0
- package/skills/django-celery/SKILL.md +458 -0
- package/skills/django-patterns/SKILL.md +735 -0
- package/skills/django-security/SKILL.md +644 -0
- package/skills/django-tdd/SKILL.md +730 -0
- package/skills/django-verification/SKILL.md +470 -0
- package/skills/dmux-workflows/SKILL.md +192 -0
- package/skills/docker-patterns/SKILL.md +365 -0
- package/skills/documentation-lookup/SKILL.md +91 -0
- package/skills/dotnet-patterns/SKILL.md +322 -0
- package/skills/dynamic-workflow-mode/SKILL.md +124 -0
- package/skills/e2e-testing/SKILL.md +327 -0
- package/skills/ecc-guide/SKILL.md +190 -0
- package/skills/ecc-recipes/SKILL.md +149 -0
- package/skills/ecc-tools-cost-audit/SKILL.md +161 -0
- package/skills/email-ops/SKILL.md +122 -0
- package/skills/energy-procurement/SKILL.md +228 -0
- package/skills/enterprise-agent-ops/SKILL.md +51 -0
- package/skills/error-handling/SKILL.md +377 -0
- package/skills/eval-harness/SKILL.md +271 -0
- package/skills/evm-token-decimals/SKILL.md +131 -0
- package/skills/exa-search/SKILL.md +108 -0
- package/skills/fal-ai-media/SKILL.md +289 -0
- package/skills/fastapi-patterns/SKILL.md +514 -0
- package/skills/finance-billing-ops/SKILL.md +128 -0
- package/skills/flox-environments/SKILL.md +497 -0
- package/skills/flutter-dart-code-review/SKILL.md +436 -0
- package/skills/foundation-models-on-device/SKILL.md +243 -0
- package/skills/frontend-a11y/SKILL.md +446 -0
- package/skills/frontend-design-direction/SKILL.md +93 -0
- package/skills/frontend-patterns/SKILL.md +657 -0
- package/skills/frontend-slides/SKILL.md +185 -0
- package/skills/fsharp-testing/SKILL.md +281 -0
- package/skills/gan-style-harness/SKILL.md +279 -0
- package/skills/gateguard/SKILL.md +133 -0
- package/skills/generating-python-installer/SKILL.md +820 -0
- package/skills/git-workflow/SKILL.md +716 -0
- package/skills/github-ops/SKILL.md +145 -0
- package/skills/golang-patterns/SKILL.md +676 -0
- package/skills/golang-testing/SKILL.md +721 -0
- package/skills/google-workspace-ops/SKILL.md +96 -0
- package/skills/growth-log/SKILL.md +128 -0
- package/skills/healthcare-cdss-patterns/SKILL.md +246 -0
- package/skills/healthcare-emr-patterns/SKILL.md +160 -0
- package/skills/healthcare-eval-harness/SKILL.md +208 -0
- package/skills/healthcare-phi-compliance/SKILL.md +146 -0
- package/skills/hermes-imports/SKILL.md +89 -0
- package/skills/hexagonal-architecture/SKILL.md +277 -0
- package/skills/hipaa-compliance/SKILL.md +79 -0
- package/skills/homelab-network-readiness/SKILL.md +170 -0
- package/skills/homelab-network-setup/SKILL.md +130 -0
- package/skills/homelab-pihole-dns/SKILL.md +275 -0
- package/skills/homelab-vlan-segmentation/SKILL.md +312 -0
- package/skills/homelab-wireguard-vpn/SKILL.md +306 -0
- package/skills/hookify-rules/SKILL.md +128 -0
- package/skills/inherit-legacy-style/SKILL.md +157 -0
- package/skills/intent-driven-development/SKILL.md +360 -0
- package/skills/inventory-demand-planning/SKILL.md +247 -0
- package/skills/investor-materials/SKILL.md +97 -0
- package/skills/investor-outreach/SKILL.md +92 -0
- package/skills/ios-icon-gen/SKILL.md +158 -0
- package/skills/iterative-retrieval/SKILL.md +212 -0
- package/skills/ito-basket-compare/SKILL.md +64 -0
- package/skills/ito-compute/SKILL.md +129 -0
- package/skills/ito-data-atlas-agent/SKILL.md +64 -0
- package/skills/ito-market-intelligence/SKILL.md +61 -0
- package/skills/ito-trade-planner/SKILL.md +68 -0
- package/skills/java-coding-standards/SKILL.md +384 -0
- package/skills/jira-integration/SKILL.md +303 -0
- package/skills/jpa-patterns/SKILL.md +152 -0
- package/skills/knowledge-ops/SKILL.md +155 -0
- package/skills/kotlin-coroutines-flows/SKILL.md +285 -0
- package/skills/kotlin-exposed-patterns/SKILL.md +720 -0
- package/skills/kotlin-ktor-patterns/SKILL.md +690 -0
- package/skills/kotlin-patterns/SKILL.md +712 -0
- package/skills/kotlin-testing/SKILL.md +825 -0
- package/skills/kubernetes-patterns/SKILL.md +756 -0
- package/skills/laravel-patterns/SKILL.md +416 -0
- package/skills/laravel-plugin-discovery/SKILL.md +230 -0
- package/skills/laravel-security/SKILL.md +948 -0
- package/skills/laravel-tdd/SKILL.md +675 -0
- package/skills/laravel-verification/SKILL.md +180 -0
- package/skills/latency-critical-systems/SKILL.md +75 -0
- package/skills/lead-intelligence/SKILL.md +322 -0
- package/skills/liquid-glass-design/SKILL.md +279 -0
- package/skills/llm-trading-agent-security/SKILL.md +147 -0
- package/skills/logistics-exception-management/SKILL.md +222 -0
- package/skills/loop-design-check/SKILL.md +143 -0
- package/skills/mailtrap-email-integration/SKILL.md +77 -0
- package/skills/make-interfaces-feel-better/SKILL.md +152 -0
- package/skills/manim-video/SKILL.md +90 -0
- package/skills/market-research/SKILL.md +76 -0
- package/skills/marketing-campaign/SKILL.md +114 -0
- package/skills/mcp-server-patterns/SKILL.md +70 -0
- package/skills/messages-ops/SKILL.md +105 -0
- package/skills/ml-adoption-playbook/SKILL.md +57 -0
- package/skills/mle-workflow/SKILL.md +348 -0
- package/skills/motion-advanced/SKILL.md +596 -0
- package/skills/motion-foundations/SKILL.md +299 -0
- package/skills/motion-patterns/SKILL.md +434 -0
- package/skills/motion-ui/SKILL.md +576 -0
- package/skills/mysql-patterns/SKILL.md +413 -0
- package/skills/nanoclaw-repl/SKILL.md +34 -0
- package/skills/nestjs-patterns/SKILL.md +231 -0
- package/skills/netmiko-ssh-automation/SKILL.md +174 -0
- package/skills/network-bgp-diagnostics/SKILL.md +168 -0
- package/skills/network-config-validation/SKILL.md +211 -0
- package/skills/network-interface-health/SKILL.md +153 -0
- package/skills/nextjs-turbopack/SKILL.md +58 -0
- package/skills/nodejs-keccak256/SKILL.md +103 -0
- package/skills/nutrient-document-processing/SKILL.md +168 -0
- package/skills/nuxt4-patterns/SKILL.md +101 -0
- package/skills/openclaw-persona-forge/SKILL.md +289 -0
- package/skills/openclaw-persona-forge/references/avatar-style.md +124 -0
- package/skills/openclaw-persona-forge/references/boundary-rules.md +53 -0
- package/skills/openclaw-persona-forge/references/error-handling.md +53 -0
- package/skills/openclaw-persona-forge/references/identity-tension.md +48 -0
- package/skills/openclaw-persona-forge/references/naming-system.md +39 -0
- package/skills/openclaw-persona-forge/references/output-template.md +166 -0
- package/skills/opensource-pipeline/SKILL.md +256 -0
- package/skills/orch-add-feature/SKILL.md +45 -0
- package/skills/orch-build-mvp/SKILL.md +49 -0
- package/skills/orch-change-feature/SKILL.md +43 -0
- package/skills/orch-fix-defect/SKILL.md +43 -0
- package/skills/orch-pipeline/SKILL.md +121 -0
- package/skills/orch-refine-code/SKILL.md +44 -0
- package/skills/parallel-execution-optimizer/SKILL.md +74 -0
- package/skills/perl-patterns/SKILL.md +505 -0
- package/skills/perl-security/SKILL.md +504 -0
- package/skills/perl-testing/SKILL.md +476 -0
- package/skills/plan-canvas/SKILL.md +153 -0
- package/skills/plan-orchestrate/SKILL.md +263 -0
- package/skills/plankton-code-quality/SKILL.md +237 -0
- package/skills/postgres-patterns/SKILL.md +148 -0
- package/skills/prediction-market-oracle-research/SKILL.md +64 -0
- package/skills/prediction-market-risk-review/SKILL.md +61 -0
- package/skills/premium-web-design/SKILL.md +154 -0
- package/skills/prisma-patterns/SKILL.md +401 -0
- package/skills/product-capability/SKILL.md +142 -0
- package/skills/product-lens/SKILL.md +93 -0
- package/skills/production-audit/SKILL.md +207 -0
- package/skills/production-scheduling/SKILL.md +238 -0
- package/skills/project-flow-ops/SKILL.md +112 -0
- package/skills/prompt-optimizer/SKILL.md +398 -0
- package/skills/python-patterns/SKILL.md +751 -0
- package/skills/python-testing/SKILL.md +817 -0
- package/skills/pytorch-patterns/SKILL.md +397 -0
- package/skills/quality-nonconformance/SKILL.md +260 -0
- package/skills/quarkus-patterns/SKILL.md +723 -0
- package/skills/quarkus-security/SKILL.md +468 -0
- package/skills/quarkus-tdd/SKILL.md +812 -0
- package/skills/quarkus-verification/SKILL.md +480 -0
- package/skills/ralphinho-rfc-pipeline/SKILL.md +68 -0
- package/skills/react-native-patterns/SKILL.md +326 -0
- package/skills/react-patterns/SKILL.md +342 -0
- package/skills/react-performance/SKILL.md +575 -0
- package/skills/react-testing/SKILL.md +424 -0
- package/skills/recsys-pipeline-architect/SKILL.md +115 -0
- package/skills/recursive-decision-ledger/SKILL.md +81 -0
- package/skills/redis-patterns/SKILL.md +404 -0
- package/skills/regex-vs-llm-structured-text/SKILL.md +221 -0
- package/skills/remotion-video-creation/SKILL.md +43 -0
- package/skills/repo-scan/SKILL.md +79 -0
- package/skills/research-ops/SKILL.md +113 -0
- package/skills/returns-reverse-logistics/SKILL.md +240 -0
- package/skills/rules-distill/SKILL.md +265 -0
- package/skills/rust-patterns/SKILL.md +500 -0
- package/skills/rust-testing/SKILL.md +501 -0
- package/skills/safety-guard/SKILL.md +76 -0
- package/skills/santa-method/SKILL.md +307 -0
- package/skills/scientific-db-pubmed-database/SKILL.md +176 -0
- package/skills/scientific-db-uspto-database/SKILL.md +178 -0
- package/skills/scientific-pkg-gget/SKILL.md +167 -0
- package/skills/scientific-thinking-literature-review/SKILL.md +193 -0
- package/skills/scientific-thinking-scholar-evaluation/SKILL.md +161 -0
- package/skills/search-first/SKILL.md +183 -0
- package/skills/security-bounty-hunter/SKILL.md +100 -0
- package/skills/security-review/SKILL.md +504 -0
- package/skills/security-scan/SKILL.md +166 -0
- package/skills/seo/SKILL.md +155 -0
- package/skills/skill-comply/SKILL.md +59 -0
- package/skills/skill-scout/SKILL.md +141 -0
- package/skills/skill-stocktake/SKILL.md +195 -0
- package/skills/social-graph-ranker/SKILL.md +155 -0
- package/skills/social-publisher/SKILL.md +130 -0
- package/skills/springboot-patterns/SKILL.md +315 -0
- package/skills/springboot-security/SKILL.md +273 -0
- package/skills/springboot-tdd/SKILL.md +159 -0
- package/skills/springboot-verification/SKILL.md +232 -0
- package/skills/strategic-compact/SKILL.md +142 -0
- package/skills/swift-actor-persistence/SKILL.md +144 -0
- package/skills/swift-concurrency-6-2/SKILL.md +216 -0
- package/skills/swift-protocol-di-testing/SKILL.md +191 -0
- package/skills/swiftui-patterns/SKILL.md +259 -0
- package/skills/taste/SKILL.md +264 -0
- package/skills/taste/references/genre-taxonomy.md +87 -0
- package/skills/tdd-workflow/SKILL.md +583 -0
- package/skills/team-agent-orchestration/SKILL.md +111 -0
- package/skills/team-builder/SKILL.md +169 -0
- package/skills/terminal-ops/SKILL.md +110 -0
- package/skills/tinystruct-patterns/SKILL.md +279 -0
- package/skills/tinystruct-patterns/references/architecture.md +90 -0
- package/skills/tinystruct-patterns/references/data-handling.md +60 -0
- package/skills/tinystruct-patterns/references/database.md +99 -0
- package/skills/tinystruct-patterns/references/routing.md +64 -0
- package/skills/tinystruct-patterns/references/system-usage.md +97 -0
- package/skills/tinystruct-patterns/references/testing.md +72 -0
- package/skills/token-budget-advisor/SKILL.md +134 -0
- package/skills/ui-demo/SKILL.md +466 -0
- package/skills/ui-to-vue/SKILL.md +135 -0
- package/skills/uncloud/SKILL.md +344 -0
- package/skills/unified-memory/SKILL.md +170 -0
- package/skills/unified-notifications-ops/SKILL.md +188 -0
- package/skills/verification-loop/SKILL.md +129 -0
- package/skills/video-editing/SKILL.md +311 -0
- package/skills/videodb/SKILL.md +375 -0
- package/skills/visa-doc-translate/SKILL.md +117 -0
- package/skills/vite-patterns/SKILL.md +450 -0
- package/skills/vue-patterns/SKILL.md +471 -0
- package/skills/windows-desktop-e2e/SKILL.md +888 -0
- package/skills/workspace-surface-audit/SKILL.md +126 -0
- package/skills/x-api/SKILL.md +235 -0
package/lib/tools.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Codeply agent tools.
|
|
3
3
|
*
|
|
4
4
|
* Each tool is a plain async function returning { ok, output, meta }. `output`
|
|
5
|
-
* is what gets fed back to the model, so it is always truncated to a sane size
|
|
5
|
+
* is what gets fed back to the model, so it is always truncated to a sane size -
|
|
6
6
|
* an untruncated file read is the fastest way to blow the context budget and
|
|
7
7
|
* make the agent stupid halfway through a task.
|
|
8
8
|
*
|
|
@@ -12,19 +12,27 @@
|
|
|
12
12
|
import fs from 'fs';
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import os from 'os';
|
|
15
|
+
import crypto from 'crypto';
|
|
15
16
|
import { exec } from 'child_process';
|
|
16
17
|
import { createRequire } from 'module';
|
|
18
|
+
import { searchLibrary, listCategories, designLibraryConfigured, CATEGORY_LABELS } from './design-library/query.mjs';
|
|
17
19
|
|
|
18
20
|
const require = createRequire(import.meta.url);
|
|
19
21
|
const editEngine = require('./edit-engine.js');
|
|
22
|
+
const terminalHints = require('./terminal-hints.js');
|
|
23
|
+
const diagnostics = require('./diagnostics.js');
|
|
24
|
+
const { commandPatterns } = require('./arity.js');
|
|
25
|
+
const mcpLib = require('./mcp.js');
|
|
20
26
|
const applyLimit = require('./apply-limit.js');
|
|
21
27
|
const config = require('./config.js');
|
|
28
|
+
const skills = require('./skills.js');
|
|
29
|
+
const oauth = require('./oauth-connectors.js');
|
|
30
|
+
const webTools = require('./web-tools.js');
|
|
31
|
+
const applyPatchLib = require('./apply-patch.js');
|
|
22
32
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
// gated by it.
|
|
27
|
-
const CAPPED_TOOLS = new Set(['write_file', 'edit_file']);
|
|
33
|
+
// Successful writes made while using the hosted model are logged (counts
|
|
34
|
+
// only) for the usage dashboard. There is no cap - see apply-limit.js.
|
|
35
|
+
const RECORDED_TOOLS = new Set(['write_file', 'edit_file']);
|
|
28
36
|
|
|
29
37
|
const MAX_TOOL_OUTPUT = 12000; // chars fed back to the model per tool call
|
|
30
38
|
const MAX_READ_LINES = 600;
|
|
@@ -43,10 +51,49 @@ const TEXT_EXT = new Set([
|
|
|
43
51
|
'.yaml', '.toml', '.xml', '.svg', '.vue', '.svelte', '.sql', '.env',
|
|
44
52
|
]);
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
// Oversized output is saved in full to a file the model can page through
|
|
55
|
+
// with read_file, instead of the rest just being lost. Same idea as
|
|
56
|
+
// opencode's tool/truncate.ts (MIT). Old files are swept after a week.
|
|
57
|
+
const SPILL_DIR = path.join(os.tmpdir(), 'codeply-tool-output');
|
|
58
|
+
const SPILL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
59
|
+
let spillSwept = false;
|
|
60
|
+
|
|
61
|
+
function spillToFile(text) {
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(SPILL_DIR, { recursive: true });
|
|
64
|
+
if (!spillSwept) {
|
|
65
|
+
spillSwept = true;
|
|
66
|
+
const cutoff = Date.now() - SPILL_MAX_AGE_MS;
|
|
67
|
+
for (const f of fs.readdirSync(SPILL_DIR)) {
|
|
68
|
+
const p = path.join(SPILL_DIR, f);
|
|
69
|
+
try { if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p); } catch {}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const file = path.join(SPILL_DIR, `out-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}.txt`);
|
|
73
|
+
fs.writeFileSync(file, text, 'utf8');
|
|
74
|
+
return file;
|
|
75
|
+
} catch { return null; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {string} text
|
|
80
|
+
* @param {number} [max]
|
|
81
|
+
* @param {{keep?: 'head'|'ends', spill?: boolean}} [opts]
|
|
82
|
+
* keep 'ends' shows the start and the end (command output: errors and
|
|
83
|
+
* summaries are usually last). spill saves the full text to a file.
|
|
84
|
+
*/
|
|
85
|
+
function truncate(text, max = MAX_TOOL_OUTPUT, { keep = 'head', spill = true } = {}) {
|
|
47
86
|
if (text.length <= max) return text;
|
|
48
|
-
const
|
|
49
|
-
|
|
87
|
+
const file = spill ? spillToFile(text) : null;
|
|
88
|
+
const where = file
|
|
89
|
+
? `The full output (${text.length} characters, ${text.split('\n').length} lines) is saved at ${file} - read_file it with offset/limit, or search inside it with <path>, instead of re-running.`
|
|
90
|
+
: 'Narrow the request if you need the rest.';
|
|
91
|
+
if (keep === 'ends') {
|
|
92
|
+
const head = text.slice(0, Math.floor(max * 0.35));
|
|
93
|
+
const tail = text.slice(text.length - Math.floor(max * 0.6));
|
|
94
|
+
return `${head}\n\n[… ${text.length - head.length - tail.length} characters cut from the middle. ${where}]\n\n${tail}`;
|
|
95
|
+
}
|
|
96
|
+
return `${text.slice(0, max)}\n\n[… truncated, ${text.length - max} more characters. ${where}]`;
|
|
50
97
|
}
|
|
51
98
|
|
|
52
99
|
/** Resolve a model-supplied path against cwd and report whether it escapes it. */
|
|
@@ -107,6 +154,138 @@ function walkFiles(root, { maxFiles = 4000, maxDepth = 12 } = {}) {
|
|
|
107
154
|
return out;
|
|
108
155
|
}
|
|
109
156
|
|
|
157
|
+
// ─── Read helpers ───────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
const MAX_LINE_CHARS = 2000;
|
|
160
|
+
|
|
161
|
+
/** NUL bytes, or mostly non-printable bytes, in the first 4KB. */
|
|
162
|
+
function looksBinary(buf) {
|
|
163
|
+
const n = Math.min(buf.length, 4096);
|
|
164
|
+
if (!n) return false;
|
|
165
|
+
let odd = 0;
|
|
166
|
+
for (let i = 0; i < n; i++) {
|
|
167
|
+
const b = buf[i];
|
|
168
|
+
if (b === 0) return true;
|
|
169
|
+
if (b < 7 || (b > 13 && b < 32)) odd++;
|
|
170
|
+
}
|
|
171
|
+
return odd / n > 0.3;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Up to 3 existing files whose names resemble a missing one. */
|
|
175
|
+
function similarPaths(abs, cwd) {
|
|
176
|
+
const want = path.basename(abs).toLowerCase();
|
|
177
|
+
const stem = want.replace(/\.[^.]+$/, '');
|
|
178
|
+
const scored = [];
|
|
179
|
+
const consider = (full) => {
|
|
180
|
+
const name = path.basename(full).toLowerCase();
|
|
181
|
+
const s = name === want ? 1 : name.replace(/\.[^.]+$/, '') === stem ? 0.9 : lineSimilarity(name, want);
|
|
182
|
+
if (s >= 0.55) scored.push([s, full]);
|
|
183
|
+
};
|
|
184
|
+
const dir = path.dirname(abs);
|
|
185
|
+
try { if (fs.existsSync(dir)) for (const f of fs.readdirSync(dir)) consider(path.join(dir, f)); } catch {}
|
|
186
|
+
// Same name somewhere else in the project is the other common slip.
|
|
187
|
+
for (const f of walkFiles(cwd, { maxFiles: 3000, maxDepth: 8 })) if (path.basename(f).toLowerCase() === want) scored.push([0.95, f]);
|
|
188
|
+
return [...new Map(scored.sort((a, b) => b[0] - a[0]).map(([, f]) => [f, f])).keys()]
|
|
189
|
+
.slice(0, 3)
|
|
190
|
+
.map((f) => resolvePath(f, cwd).rel);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Edit helpers ───────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
// ctx.fileState maps an absolute path to the mtime it had when this turn last
|
|
196
|
+
// read or wrote it. If the file changes on disk after that (the user saved it
|
|
197
|
+
// in their editor, a formatter ran), an edit built from the old read would be
|
|
198
|
+
// matched against content the model has never seen, so it is refused until
|
|
199
|
+
// the file is read again.
|
|
200
|
+
function rememberDiskState(ctx, abs) {
|
|
201
|
+
if (!ctx.fileState) return;
|
|
202
|
+
try { ctx.fileState.set(abs, fs.statSync(abs).mtimeMs); } catch {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function changedSinceSeen(ctx, abs) {
|
|
206
|
+
if (!ctx.fileState || !ctx.fileState.has(abs)) return false;
|
|
207
|
+
try { return fs.statSync(abs).mtimeMs !== ctx.fileState.get(abs); } catch { return false; }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function bigrams(s) {
|
|
211
|
+
const out = new Map();
|
|
212
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
213
|
+
const g = s.slice(i, i + 2);
|
|
214
|
+
out.set(g, (out.get(g) || 0) + 1);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Dice similarity of two trimmed lines, 0..1. */
|
|
220
|
+
function lineSimilarity(a, b) {
|
|
221
|
+
if (a === b) return 1;
|
|
222
|
+
if (a.length < 2 || b.length < 2) return 0;
|
|
223
|
+
const A = bigrams(a), B = bigrams(b);
|
|
224
|
+
let overlap = 0;
|
|
225
|
+
for (const [g, n] of A) overlap += Math.min(n, B.get(g) || 0);
|
|
226
|
+
return (2 * overlap) / (a.length - 1 + b.length - 1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* When a search block does not match, show the model the region of the file
|
|
231
|
+
* it most likely meant, with line numbers, so it can copy the real text on
|
|
232
|
+
* the next reply instead of re-reading a long file from the top or guessing
|
|
233
|
+
* again from memory.
|
|
234
|
+
*/
|
|
235
|
+
function nearestMatchHint(content, search) {
|
|
236
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
237
|
+
const want = search.replace(/\r\n/g, '\n').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
238
|
+
if (!want.length || !lines.length) return '';
|
|
239
|
+
|
|
240
|
+
let best = -1, bestScore = 0;
|
|
241
|
+
for (let i = 0; i < lines.length; i++) {
|
|
242
|
+
const t = lines[i].trim();
|
|
243
|
+
if (!t) continue;
|
|
244
|
+
// Score a candidate start by how well the next few file lines line up
|
|
245
|
+
// with the first few search lines, not by one line alone - a single
|
|
246
|
+
// closing brace or "return x;" matches all over a file.
|
|
247
|
+
let score = 0, n = 0;
|
|
248
|
+
for (let k = 0, fi = i; k < Math.min(3, want.length) && fi < lines.length; fi++) {
|
|
249
|
+
const ft = lines[fi].trim();
|
|
250
|
+
if (!ft) continue;
|
|
251
|
+
score += lineSimilarity(ft, want[k]);
|
|
252
|
+
n++; k++;
|
|
253
|
+
}
|
|
254
|
+
score = n ? score / Math.min(3, want.length) : 0;
|
|
255
|
+
if (score > bestScore) { bestScore = score; best = i; }
|
|
256
|
+
}
|
|
257
|
+
if (best === -1 || bestScore < 0.45) return '';
|
|
258
|
+
|
|
259
|
+
const from = Math.max(0, best - 2);
|
|
260
|
+
const to = Math.min(lines.length, best + want.length + 3);
|
|
261
|
+
const width = String(to).length;
|
|
262
|
+
const excerpt = lines.slice(from, to).map((l, i) => `${String(from + i + 1).padStart(width)}│${l}`).join('\n');
|
|
263
|
+
return `\nClosest match in the file right now (lines ${from + 1}-${to}). Copy your search text from here, without the line-number prefix:\n${excerpt}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 1-based line numbers where an exact block starts. */
|
|
267
|
+
function exactMatchLines(content, block) {
|
|
268
|
+
const text = content.replace(/\r\n/g, '\n');
|
|
269
|
+
const needle = block.replace(/\r\n/g, '\n');
|
|
270
|
+
const out = [];
|
|
271
|
+
for (let at = text.indexOf(needle); at !== -1 && out.length < 20; at = text.indexOf(needle, at + 1)) {
|
|
272
|
+
out.push(text.slice(0, at).split('\n').length);
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isTruthy(v) {
|
|
278
|
+
return /^(1|true|yes|all)$/i.test(String(v || '').trim());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Plan mode's one exception to "read-only": the plan file itself.
|
|
282
|
+
const PLAN_DIR = '.codeply/plans';
|
|
283
|
+
function isPlanPath(p, cwd) {
|
|
284
|
+
if (!p) return false;
|
|
285
|
+
const { rel, outside } = resolvePath(String(p), cwd);
|
|
286
|
+
return !outside && /^\.codeply\/plans\/[^/]+\.md$/i.test(rel);
|
|
287
|
+
}
|
|
288
|
+
|
|
110
289
|
// ─── Tools ──────────────────────────────────────────────────────────────────
|
|
111
290
|
|
|
112
291
|
async function list_dir(args, ctx) {
|
|
@@ -139,30 +318,52 @@ async function read_file(args, ctx) {
|
|
|
139
318
|
const target = args.path;
|
|
140
319
|
if (!target) return { ok: false, output: 'read_file needs a <path>.' };
|
|
141
320
|
const { abs, rel } = resolvePath(target, ctx.cwd);
|
|
142
|
-
if (!fs.existsSync(abs))
|
|
143
|
-
|
|
321
|
+
if (!fs.existsSync(abs)) {
|
|
322
|
+
const alts = similarPaths(abs, ctx.cwd);
|
|
323
|
+
return { ok: false, output: `No such file: ${rel}${alts.length ? `. Did you mean: ${alts.join(', ')}?` : ''}` };
|
|
324
|
+
}
|
|
325
|
+
if (fs.statSync(abs).isDirectory()) return { ok: false, output: `${rel} is a directory - use list_dir.` };
|
|
144
326
|
|
|
145
|
-
let
|
|
146
|
-
try {
|
|
327
|
+
let buf;
|
|
328
|
+
try { buf = fs.readFileSync(abs); }
|
|
147
329
|
catch (e) { return { ok: false, output: `Cannot read ${rel}: ${e.message}` }; }
|
|
330
|
+
if (looksBinary(buf)) {
|
|
331
|
+
return { ok: false, output: `${rel} is a binary file (${buf.length} bytes), not text. Don't read it; use view_images for pictures, or a tool that understands the format.` };
|
|
332
|
+
}
|
|
333
|
+
const content = buf.toString('utf8');
|
|
148
334
|
|
|
149
335
|
const allLines = content.split('\n');
|
|
150
336
|
const offset = Math.max(0, parseInt(args.offset, 10) || 0);
|
|
337
|
+
if (offset > 0 && offset >= allLines.length) {
|
|
338
|
+
return { ok: false, output: `${rel} has only ${allLines.length} lines, so offset ${offset} is past the end. Use an offset below ${allLines.length}.` };
|
|
339
|
+
}
|
|
151
340
|
const limit = Math.min(parseInt(args.limit, 10) || MAX_READ_LINES, MAX_READ_LINES);
|
|
152
341
|
const slice = allLines.slice(offset, offset + limit);
|
|
153
342
|
const width = String(offset + slice.length).length;
|
|
154
343
|
|
|
344
|
+
// Minified bundles put a whole file on one line; one of those would eat
|
|
345
|
+
// the entire output budget. Lines are shown without a CRLF file's \r.
|
|
155
346
|
const numbered = slice
|
|
156
|
-
.map((l, i) =>
|
|
347
|
+
.map((l, i) => {
|
|
348
|
+
const line = l.replace(/\r$/, '');
|
|
349
|
+
const shown = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}… (line cut, ${line.length} chars)` : line;
|
|
350
|
+
return `${String(offset + i + 1).padStart(width)}│${shown}`;
|
|
351
|
+
})
|
|
157
352
|
.join('\n');
|
|
158
353
|
const more = allLines.length > offset + slice.length
|
|
159
354
|
? `\n[… ${allLines.length - offset - slice.length} more lines. Re-read with offset=${offset + slice.length}.]`
|
|
160
355
|
: '';
|
|
161
356
|
|
|
357
|
+
rememberDiskState(ctx, abs);
|
|
162
358
|
return {
|
|
163
359
|
ok: true,
|
|
164
|
-
output: truncate(`${rel} (${allLines.length} lines)\n${numbered}${more}
|
|
165
|
-
|
|
360
|
+
output: truncate(`${rel} (${allLines.length} lines)\n${numbered}${more}`, MAX_TOOL_OUTPUT, { spill: false }),
|
|
361
|
+
// offset/linesShown/hasMore let agent.mjs auto-continue a later call on
|
|
362
|
+
// this same path that omits <offset> - a model re-requesting "the rest"
|
|
363
|
+
// of a long file doesn't reliably track and restate the right offset
|
|
364
|
+
// itself, so leaving that entirely up to it meant it would often just
|
|
365
|
+
// land back at the top again instead of actually seeing new content.
|
|
366
|
+
meta: { label: rel, count: allLines.length, offset, linesShown: slice.length, hasMore: allLines.length > offset + slice.length },
|
|
166
367
|
};
|
|
167
368
|
}
|
|
168
369
|
|
|
@@ -176,14 +377,15 @@ async function write_file(args, ctx) {
|
|
|
176
377
|
const before = existed ? fs.readFileSync(abs, 'utf8') : '';
|
|
177
378
|
const after = args.content;
|
|
178
379
|
if (existed && before === after) {
|
|
179
|
-
return { ok: true, output: `${rel} already has exactly this content
|
|
380
|
+
return { ok: true, output: `${rel} already has exactly this content - nothing written.`, meta: { label: rel, noop: true } };
|
|
180
381
|
}
|
|
181
382
|
|
|
182
383
|
const beforeLines = existed ? before.split('\n').length : 0;
|
|
183
384
|
const afterLines = after.split('\n').length;
|
|
184
385
|
|
|
185
|
-
const verdict = await ctx.approve({
|
|
386
|
+
const verdict = ctx.mode === 'Plan' && isPlanPath(target, ctx.cwd) ? 'allow' : await ctx.approve({
|
|
186
387
|
tool: 'write_file',
|
|
388
|
+
path: rel,
|
|
187
389
|
title: existed ? `Overwrite ${rel}` : `Create ${rel}`,
|
|
188
390
|
detail: existed
|
|
189
391
|
? `${beforeLines} lines → ${afterLines} lines`
|
|
@@ -199,15 +401,23 @@ async function write_file(args, ctx) {
|
|
|
199
401
|
} catch (e) {
|
|
200
402
|
return { ok: false, output: `Cannot write ${rel}: ${e.message}` };
|
|
201
403
|
}
|
|
404
|
+
rememberDiskState(ctx, abs);
|
|
405
|
+
const problems = await diagnostics.checkFile(abs, ctx.cwd);
|
|
202
406
|
|
|
203
407
|
return {
|
|
204
408
|
ok: true,
|
|
205
|
-
output: `Wrote ${rel} (${afterLines} lines). The write succeeded exactly as you sent it
|
|
206
|
-
`do NOT read the file back to confirm it
|
|
207
|
-
meta: { label: rel, added: afterLines, removed: beforeLines, wrote: true },
|
|
409
|
+
output: `Wrote ${rel} (${afterLines} lines). The write succeeded exactly as you sent it - ` +
|
|
410
|
+
`do NOT read the file back to confirm it.` + problemsNote(rel, problems),
|
|
411
|
+
meta: { label: rel, added: afterLines, removed: beforeLines, wrote: true, problems },
|
|
208
412
|
};
|
|
209
413
|
}
|
|
210
414
|
|
|
415
|
+
function problemsNote(rel, problems) {
|
|
416
|
+
if (!problems.length) return '';
|
|
417
|
+
return `\n\nSyntax check on ${rel} found ${problems.length === 1 ? 'a problem' : `${problems.length} problems`} ` +
|
|
418
|
+
`(fix before moving on):\n${problems.map((p) => `- ${p}`).join('\n')}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
211
421
|
async function edit_file(args, ctx) {
|
|
212
422
|
const target = args.path;
|
|
213
423
|
if (!target) return { ok: false, output: 'edit_file needs a <path>.' };
|
|
@@ -217,24 +427,55 @@ async function edit_file(args, ctx) {
|
|
|
217
427
|
const { abs, rel, outside } = resolvePath(target, ctx.cwd);
|
|
218
428
|
if (!fs.existsSync(abs)) return { ok: false, output: `No such file: ${rel}. Use write_file to create it.` };
|
|
219
429
|
|
|
430
|
+
if (changedSinceSeen(ctx, abs)) {
|
|
431
|
+
ctx.fileState.delete(abs);
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
output: `Edit to ${rel} did not apply: the file changed on disk after you last read it (edited outside this conversation). ` +
|
|
435
|
+
'Read it again and build the edit from its current content.',
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
220
439
|
const before = fs.readFileSync(abs, 'utf8');
|
|
440
|
+
const replaceAll = isTruthy(args.all);
|
|
221
441
|
// Dry-run through the same matcher that will do the real edit, so the user
|
|
222
442
|
// is never asked to approve something that turns out not to apply.
|
|
223
|
-
|
|
443
|
+
let attempt;
|
|
444
|
+
let occurrences = 1;
|
|
445
|
+
if (replaceAll) {
|
|
446
|
+
// Every occurrence, exact text only: the whitespace-tolerant matcher is
|
|
447
|
+
// for finding ONE intended block, and applying its guesses file-wide
|
|
448
|
+
// would be a good way to change lines nobody meant to touch.
|
|
449
|
+
const text = before.replace(/\r\n/g, '\n');
|
|
450
|
+
const needle = args.search.replace(/\r\n/g, '\n');
|
|
451
|
+
occurrences = needle ? text.split(needle).length - 1 : 0;
|
|
452
|
+
attempt = occurrences > 0
|
|
453
|
+
? { ok: true, content: text.split(needle).join(args.replace.replace(/\r\n/g, '\n')) }
|
|
454
|
+
: { ok: false, error: needle ? 'notfound' : 'empty' };
|
|
455
|
+
if (attempt.ok && /\r\n/.test(before)) attempt.content = attempt.content.replace(/\n/g, '\r\n');
|
|
456
|
+
} else {
|
|
457
|
+
attempt = editEngine.applySearchReplace(before, args.search, args.replace);
|
|
458
|
+
}
|
|
224
459
|
if (!attempt.ok) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
460
|
+
let why;
|
|
461
|
+
if (attempt.error === 'notfound') {
|
|
462
|
+
why = 'that exact text is not in the file.' + (nearestMatchHint(before, args.search) || ' Re-read the part you want to change and copy the block verbatim.');
|
|
463
|
+
} else if (attempt.error === 'multiple') {
|
|
464
|
+
const at = exactMatchLines(before, args.search);
|
|
465
|
+
why = `that text appears more than once${at.length ? ` (starting at lines ${at.join(', ')})` : ''}. ` +
|
|
466
|
+
'Include more surrounding lines so it matches exactly one place, or add <all>true</all> if every occurrence should change.';
|
|
467
|
+
} else {
|
|
468
|
+
why = 'the search block was empty.';
|
|
469
|
+
}
|
|
470
|
+
return { ok: false, output: `Edit to ${rel} did not apply: ${why}` };
|
|
231
471
|
}
|
|
232
472
|
|
|
233
|
-
const removed = args.search.split('\n').length;
|
|
234
|
-
const added = args.replace.split('\n').length;
|
|
473
|
+
const removed = args.search.split('\n').length * occurrences;
|
|
474
|
+
const added = args.replace.split('\n').length * occurrences;
|
|
235
475
|
|
|
236
|
-
const verdict = await ctx.approve({
|
|
476
|
+
const verdict = ctx.mode === 'Plan' && isPlanPath(target, ctx.cwd) ? 'allow' : await ctx.approve({
|
|
237
477
|
tool: 'edit_file',
|
|
478
|
+
path: rel,
|
|
238
479
|
title: `Edit ${rel}`,
|
|
239
480
|
detail: `-${removed} +${added} lines`,
|
|
240
481
|
diff: { search: args.search, replace: args.replace },
|
|
@@ -244,6 +485,8 @@ async function edit_file(args, ctx) {
|
|
|
244
485
|
|
|
245
486
|
try { fs.writeFileSync(abs, attempt.content, 'utf8'); }
|
|
246
487
|
catch (e) { return { ok: false, output: `Cannot write ${rel}: ${e.message}` }; }
|
|
488
|
+
rememberDiskState(ctx, abs);
|
|
489
|
+
const problems = await diagnostics.checkFile(abs, ctx.cwd);
|
|
247
490
|
|
|
248
491
|
const nowLines = attempt.content.split('\n').length;
|
|
249
492
|
return {
|
|
@@ -251,29 +494,69 @@ async function edit_file(args, ctx) {
|
|
|
251
494
|
// The "do not re-read" line is load-bearing: without it the model burns a
|
|
252
495
|
// whole extra round-trip reading the file back, and stuffs the entire file
|
|
253
496
|
// into context again, which slows down every following step.
|
|
254
|
-
output: `Edited ${rel} (-${removed} +${added} lines). The file is now ${nowLines} lines. ` +
|
|
255
|
-
`The change was matched and applied successfully
|
|
256
|
-
|
|
497
|
+
output: `Edited ${rel} (-${removed} +${added} lines${occurrences > 1 ? `, ${occurrences} occurrences` : ''}). The file is now ${nowLines} lines. ` +
|
|
498
|
+
`The change was matched and applied successfully - do NOT read the file back to confirm it.` +
|
|
499
|
+
(attempt.fuzzy ? ' (Your search text did not match exactly; the closest unique block was used. Copy search text exactly next time.)' : '') +
|
|
500
|
+
problemsNote(rel, problems),
|
|
501
|
+
meta: { label: rel, added, removed, wrote: true, problems },
|
|
257
502
|
};
|
|
258
503
|
}
|
|
259
504
|
|
|
505
|
+
const MAX_SEARCH_CONTEXT = 5;
|
|
506
|
+
const MAX_FOUND_FILES = 200;
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Content search, or a file-name search when only <glob> is given.
|
|
510
|
+
*
|
|
511
|
+
* <pattern> regex matched per line (case-insensitive)
|
|
512
|
+
* <glob> limit to matching paths; alone, it lists matching files
|
|
513
|
+
* <path> folder to search under (default: project root)
|
|
514
|
+
* <context> lines of surrounding code to show per hit (0-5), so a hit
|
|
515
|
+
* can often be edited without a separate read_file
|
|
516
|
+
* <files_only> true: list each matching file once with its hit count
|
|
517
|
+
*/
|
|
260
518
|
async function search(args, ctx) {
|
|
261
519
|
const pattern = args.pattern;
|
|
262
|
-
|
|
520
|
+
const globRe = args.glob ? globToRegex(args.glob) : null;
|
|
521
|
+
if (!pattern && !globRe) return { ok: false, output: 'search needs a <pattern>, or a <glob> to find files by name.' };
|
|
263
522
|
|
|
264
|
-
let re;
|
|
265
|
-
|
|
266
|
-
|
|
523
|
+
let re = null;
|
|
524
|
+
if (pattern) {
|
|
525
|
+
try { re = new RegExp(pattern, 'i'); }
|
|
526
|
+
catch (e) { return { ok: false, output: `Invalid regex: ${e.message}` }; }
|
|
527
|
+
}
|
|
267
528
|
|
|
268
|
-
const globRe = args.glob ? globToRegex(args.glob) : null;
|
|
269
529
|
const root = args.path ? resolvePath(args.path, ctx.cwd).abs : ctx.cwd;
|
|
270
|
-
|
|
530
|
+
if (!fs.existsSync(root)) return { ok: false, output: `No such folder: ${args.path}` };
|
|
531
|
+
// A single file works too (e.g. a saved long command output).
|
|
532
|
+
const files = fs.statSync(root).isFile() ? [root] : walkFiles(root);
|
|
271
533
|
|
|
534
|
+
if (!re) {
|
|
535
|
+
// Also match against just the file name, so "*.test.js" finds nested files
|
|
536
|
+
// without the model having to know to write "**/*.test.js".
|
|
537
|
+
const found = files
|
|
538
|
+
.map((f) => path.relative(ctx.cwd, f).replace(/\\/g, '/'))
|
|
539
|
+
.filter((rel) => globRe.test(rel) || globRe.test(rel.split('/').pop()))
|
|
540
|
+
.sort();
|
|
541
|
+
const shown = found.slice(0, MAX_FOUND_FILES);
|
|
542
|
+
const header = found.length
|
|
543
|
+
? `${found.length} file(s) matching ${args.glob}${found.length > shown.length ? ` (first ${shown.length} shown)` : ''}:`
|
|
544
|
+
: `No files matching ${args.glob}.`;
|
|
545
|
+
return { ok: true, output: truncate(`${header}\n${shown.join('\n')}`), meta: { label: args.glob, count: found.length } };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const context = Math.min(MAX_SEARCH_CONTEXT, Math.max(0, parseInt(args.context, 10) || 0));
|
|
549
|
+
const filesOnly = isTruthy(args.files_only);
|
|
550
|
+
|
|
551
|
+
// Context blocks spend several output lines per hit, so the cap scales with them.
|
|
552
|
+
const maxLines = MAX_SEARCH_HITS * (context ? 4 : 1);
|
|
272
553
|
const hits = [];
|
|
554
|
+
const fileCounts = [];
|
|
555
|
+
let totalHits = 0;
|
|
273
556
|
let scanned = 0;
|
|
274
557
|
for (const file of files) {
|
|
275
558
|
const rel = path.relative(ctx.cwd, file).replace(/\\/g, '/');
|
|
276
|
-
if (globRe && !globRe.test(rel)) continue;
|
|
559
|
+
if (globRe && !globRe.test(rel) && !globRe.test(rel.split('/').pop())) continue;
|
|
277
560
|
const ext = path.extname(file).toLowerCase();
|
|
278
561
|
if (ext && !TEXT_EXT.has(ext)) continue;
|
|
279
562
|
let content;
|
|
@@ -281,37 +564,698 @@ async function search(args, ctx) {
|
|
|
281
564
|
if (fs.statSync(file).size > 2_000_000) continue;
|
|
282
565
|
content = fs.readFileSync(file, 'utf8');
|
|
283
566
|
} catch { continue; }
|
|
567
|
+
if (content.includes('\u0000')) continue; // binary that happens to have a text extension
|
|
284
568
|
scanned++;
|
|
285
569
|
const lines = content.split('\n');
|
|
570
|
+
let inFile = 0;
|
|
571
|
+
let lastShown = -1;
|
|
286
572
|
for (let i = 0; i < lines.length; i++) {
|
|
287
|
-
if (re.test(lines[i]))
|
|
573
|
+
if (!re.test(lines[i])) continue;
|
|
574
|
+
inFile++;
|
|
575
|
+
totalHits++;
|
|
576
|
+
if (filesOnly || hits.length >= maxLines) continue;
|
|
577
|
+
if (!context) {
|
|
288
578
|
hits.push(`${rel}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
|
|
289
|
-
|
|
579
|
+
continue;
|
|
290
580
|
}
|
|
581
|
+
// Context blocks: hit line marked with ":", neighbours with "-", and
|
|
582
|
+
// overlapping windows merged so nearby hits don't repeat lines.
|
|
583
|
+
const from = Math.max(lastShown + 1, i - context);
|
|
584
|
+
const to = Math.min(lines.length - 1, i + context);
|
|
585
|
+
if (from > lastShown + 1 || lastShown === -1) hits.push(`--`);
|
|
586
|
+
for (let k = from; k <= to; k++) {
|
|
587
|
+
const mark = re.test(lines[k]) ? ':' : '-';
|
|
588
|
+
hits.push(`${rel}${mark}${k + 1}${mark} ${lines[k].slice(0, 200)}`);
|
|
589
|
+
}
|
|
590
|
+
lastShown = to;
|
|
291
591
|
}
|
|
292
|
-
if (
|
|
592
|
+
if (inFile) fileCounts.push(`${rel} (${inFile})`);
|
|
593
|
+
if (!filesOnly && hits.length >= maxLines) break;
|
|
293
594
|
}
|
|
294
595
|
|
|
295
|
-
|
|
296
|
-
|
|
596
|
+
if (filesOnly) {
|
|
597
|
+
const header = fileCounts.length
|
|
598
|
+
? `${totalHits} match(es) in ${fileCounts.length} file(s), out of ${scanned} scanned:`
|
|
599
|
+
: `No matches for /${pattern}/ across ${scanned} file(s).`;
|
|
600
|
+
return { ok: true, output: truncate(`${header}\n${fileCounts.join('\n')}`), meta: { label: pattern, count: totalHits } };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const capped = hits.length >= maxLines;
|
|
604
|
+
const header = totalHits
|
|
605
|
+
? `${totalHits}${capped ? '+' : ''} match(es) across ${scanned} file(s)${capped ? '. Output capped: narrow with <glob>/<path>, or use <files_only>true</files_only> to see which files match' : ''}:`
|
|
297
606
|
: `No matches for /${pattern}/ across ${scanned} file(s).`;
|
|
298
|
-
return { ok: true, output: truncate(`${header}\n${hits.join('\n')}`), meta: { label: pattern, count:
|
|
607
|
+
return { ok: true, output: truncate(`${header}\n${hits.join('\n')}`), meta: { label: pattern, count: totalHits } };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // 8MB - plenty for web assets, small enough to not stall a turn
|
|
611
|
+
const FETCH_TIMEOUT_MS = 20000;
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Download an image from the internet and save it to disk. This is the
|
|
615
|
+
* agent's only sanctioned path to the network for assets - `run` can already
|
|
616
|
+
* reach curl/Invoke-WebRequest, but that gives the model no feedback about
|
|
617
|
+
* whether it actually got an image back, no size cap, and a generic "Run
|
|
618
|
+
* command" approval prompt instead of one that tells the user what's about
|
|
619
|
+
* to land on their disk and from where.
|
|
620
|
+
*/
|
|
621
|
+
async function fetch_image(args, ctx) {
|
|
622
|
+
const url = (args.url || '').trim();
|
|
623
|
+
const target = (args.path || '').trim();
|
|
624
|
+
if (!url) return { ok: false, output: 'fetch_image needs a <url>.' };
|
|
625
|
+
if (!target) return { ok: false, output: 'fetch_image needs a <path> to save the image to.' };
|
|
626
|
+
|
|
627
|
+
let parsed;
|
|
628
|
+
try { parsed = new URL(url); }
|
|
629
|
+
catch { return { ok: false, output: `Not a valid URL: ${url}` }; }
|
|
630
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
631
|
+
return { ok: false, output: 'fetch_image only supports http:// and https:// URLs.' };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const { abs, rel, outside } = resolvePath(target, ctx.cwd);
|
|
635
|
+
|
|
636
|
+
// `path` rides alongside `detail` (the original url) so a host that wants to
|
|
637
|
+
// offer a real picker doesn't have to scrape the human-readable `title` text
|
|
638
|
+
// to know where the image is headed.
|
|
639
|
+
const verdict = await ctx.approve({
|
|
640
|
+
tool: 'fetch_image',
|
|
641
|
+
title: `Download image to ${rel}`,
|
|
642
|
+
detail: url,
|
|
643
|
+
path: rel,
|
|
644
|
+
danger: outside,
|
|
645
|
+
});
|
|
646
|
+
const verdictAction = typeof verdict === 'string' ? verdict : verdict?.action;
|
|
647
|
+
if (verdictAction === 'reject') return { ok: false, output: `User declined the download to ${rel}.`, meta: { rejected: true } };
|
|
648
|
+
|
|
649
|
+
// A host UI (e.g. an image picker the user searched and clicked through)
|
|
650
|
+
// can hand back a different URL than the one the model proposed - that
|
|
651
|
+
// replaces it here, but everything downstream (fetch, validation, write)
|
|
652
|
+
// is identical either way.
|
|
653
|
+
const fetchUrl = (verdict && typeof verdict === 'object' && verdict.url) ? verdict.url : url;
|
|
654
|
+
|
|
655
|
+
let res;
|
|
656
|
+
try {
|
|
657
|
+
res = await fetch(fetchUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'follow' });
|
|
658
|
+
} catch (e) {
|
|
659
|
+
return { ok: false, output: `Could not reach ${fetchUrl}: ${e.message}` };
|
|
660
|
+
}
|
|
661
|
+
if (!res.ok) return { ok: false, output: `${fetchUrl} returned HTTP ${res.status}.` };
|
|
662
|
+
|
|
663
|
+
const contentType = (res.headers.get('content-type') || '').split(';')[0].trim();
|
|
664
|
+
if (!contentType.startsWith('image/')) {
|
|
665
|
+
return { ok: false, output: `${fetchUrl} did not return an image (content-type: ${contentType || 'unknown'}). Nothing was saved.` };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let buf;
|
|
669
|
+
try { buf = Buffer.from(await res.arrayBuffer()); }
|
|
670
|
+
catch (e) { return { ok: false, output: `Failed reading the response from ${fetchUrl}: ${e.message}` }; }
|
|
671
|
+
|
|
672
|
+
if (buf.length > MAX_IMAGE_BYTES) {
|
|
673
|
+
return { ok: false, output: `Image is ${(buf.length / 1e6).toFixed(1)}MB, over the ${MAX_IMAGE_BYTES / 1e6}MB limit. Try a smaller/compressed source.` };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
const dir = path.dirname(abs);
|
|
678
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
679
|
+
fs.writeFileSync(abs, buf);
|
|
680
|
+
} catch (e) {
|
|
681
|
+
return { ok: false, output: `Cannot write ${rel}: ${e.message}` };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return {
|
|
685
|
+
ok: true,
|
|
686
|
+
output: `Downloaded ${rel} (${contentType}, ${(buf.length / 1024).toFixed(0)}KB) from ${fetchUrl}. ` +
|
|
687
|
+
`Reference it in markup with a normal relative path - do NOT read the file back to confirm it.`,
|
|
688
|
+
meta: { label: rel, wrote: true },
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Gmail access tokens expire in ~1hr; the refresh token doesn't. Refreshed
|
|
694
|
+
* transparently here before every call rather than waiting for a 401, since
|
|
695
|
+
* "expired 4 minutes ago" and "expires in 4 minutes" both need the same
|
|
696
|
+
* refresh anyway and there's no benefit to waiting for the failure first.
|
|
697
|
+
*/
|
|
698
|
+
async function getValidGmailToken() {
|
|
699
|
+
const gmail = config.getIntegration('gmail');
|
|
700
|
+
if (!gmail.accessToken) return null;
|
|
701
|
+
if (gmail.expiresAt && Date.now() < gmail.expiresAt - 60000) return gmail.accessToken;
|
|
702
|
+
if (!gmail.refreshToken) return gmail.accessToken; // nothing to refresh with - let the call itself fail if it's actually expired
|
|
703
|
+
const refreshed = await oauth.refreshGmailToken(gmail.clientId, gmail.clientSecret, gmail.refreshToken);
|
|
704
|
+
config.saveIntegration('gmail', { accessToken: refreshed.access_token, expiresAt: Date.now() + (refreshed.expires_in || 3600) * 1000 });
|
|
705
|
+
return refreshed.access_token;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function gmail_send(args, ctx) {
|
|
709
|
+
const to = (args.to || '').trim();
|
|
710
|
+
const subject = (args.subject || '').trim();
|
|
711
|
+
const body = args.body || '';
|
|
712
|
+
if (!to) return { ok: false, output: 'gmail_send needs a <to> address.' };
|
|
713
|
+
if (!subject) return { ok: false, output: 'gmail_send needs a <subject>.' };
|
|
714
|
+
|
|
715
|
+
const token = await getValidGmailToken();
|
|
716
|
+
if (!token) return { ok: false, output: 'Gmail is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
717
|
+
|
|
718
|
+
const verdict = await ctx.approve({
|
|
719
|
+
tool: 'gmail_send',
|
|
720
|
+
title: `Send email to ${to}`,
|
|
721
|
+
detail: `Subject: ${subject}\n\n${body}`,
|
|
722
|
+
danger: false,
|
|
723
|
+
});
|
|
724
|
+
if (verdict === 'reject') return { ok: false, output: `User declined to send the email to ${to}.`, meta: { rejected: true } };
|
|
725
|
+
|
|
726
|
+
try {
|
|
727
|
+
const result = await oauth.gmailSend(token, { to, subject, body });
|
|
728
|
+
return { ok: true, output: `Email sent to ${to} (message id ${result.id}).`, meta: { label: `to ${to}: ${subject}` } };
|
|
729
|
+
} catch (e) {
|
|
730
|
+
return { ok: false, output: `Gmail send failed: ${e.message}` };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function gmail_search(args, ctx) {
|
|
735
|
+
const query = (args.query || '').trim();
|
|
736
|
+
if (!query) return { ok: false, output: 'gmail_search needs a <query> (Gmail search syntax, e.g. "from:x@y.com is:unread").' };
|
|
737
|
+
|
|
738
|
+
const token = await getValidGmailToken();
|
|
739
|
+
if (!token) return { ok: false, output: 'Gmail is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
740
|
+
|
|
741
|
+
try {
|
|
742
|
+
const results = await oauth.gmailSearch(token, query);
|
|
743
|
+
if (!results.length) return { ok: true, output: `No messages matched "${query}".`, meta: { label: query } };
|
|
744
|
+
const lines = results.map((m) => `- ${m.subject || '(no subject)'} - from ${m.from} - ${m.date}\n ${m.snippet}`);
|
|
745
|
+
return { ok: true, output: `${results.length} message(s) matching "${query}":\n\n${lines.join('\n')}`, meta: { label: query, count: results.length } };
|
|
746
|
+
} catch (e) {
|
|
747
|
+
return { ok: false, output: `Gmail search failed: ${e.message}` };
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Slack's own docs are inconsistent about whether chat.postMessage accepts a
|
|
752
|
+
// bare channel name - it's legacy behavior some workspaces get and others
|
|
753
|
+
// don't. Resolving to a real ID via conversations.list first is the only
|
|
754
|
+
// reliable path, and it's one extra call, not a real cost.
|
|
755
|
+
async function resolveSlackChannel(accessToken, input) {
|
|
756
|
+
const name = input.replace(/^#/, '').toLowerCase();
|
|
757
|
+
if (/^[CGD][A-Z0-9]{8,}$/.test(input)) return input; // already looks like a real Slack ID
|
|
758
|
+
const channels = await oauth.slackListChannels(accessToken);
|
|
759
|
+
const match = channels.find((c) => c.name.toLowerCase() === name);
|
|
760
|
+
if (!match) throw new Error(`No channel named "${input}" found (or the bot hasn't been added to it).`);
|
|
761
|
+
return match.id;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function slack_post_message(args, ctx) {
|
|
765
|
+
const channelInput = (args.channel || '').trim();
|
|
766
|
+
const text = args.text || '';
|
|
767
|
+
if (!channelInput) return { ok: false, output: 'slack_post_message needs a <channel> (name like "general" or a channel ID).' };
|
|
768
|
+
if (!text.trim()) return { ok: false, output: 'slack_post_message needs <text> to send.' };
|
|
769
|
+
|
|
770
|
+
const slack = config.getIntegration('slack');
|
|
771
|
+
if (!slack.accessToken) return { ok: false, output: 'Slack is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
772
|
+
|
|
773
|
+
const verdict = await ctx.approve({
|
|
774
|
+
tool: 'slack_post_message',
|
|
775
|
+
title: `Post to #${channelInput.replace(/^#/, '')}`,
|
|
776
|
+
detail: text,
|
|
777
|
+
danger: false,
|
|
778
|
+
});
|
|
779
|
+
if (verdict === 'reject') return { ok: false, output: `User declined to post to ${channelInput}.`, meta: { rejected: true } };
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
// Channel lookup always uses the bot token - channels:read is a bot-only
|
|
783
|
+
// scope in this app's setup regardless of which token ends up posting.
|
|
784
|
+
const channelId = await resolveSlackChannel(slack.accessToken, channelInput);
|
|
785
|
+
// Posting defaults to the user token when connected - messages read as
|
|
786
|
+
// coming from the actual person, not the "Codeply Craft APP" bot. Falls
|
|
787
|
+
// back to the bot token for anyone who connected before user-token
|
|
788
|
+
// support existed, or who only approved the bot scopes.
|
|
789
|
+
const useUserToken = !!slack.userAccessToken;
|
|
790
|
+
const postToken = useUserToken ? slack.userAccessToken : slack.accessToken;
|
|
791
|
+
await oauth.slackPostMessage(postToken, { channel: channelId, text }, { isUserToken: useUserToken });
|
|
792
|
+
return { ok: true, output: `Posted to ${channelInput}${useUserToken ? ' as you' : ''}.`, meta: { label: `#${channelInput.replace(/^#/, '')}` } };
|
|
793
|
+
} catch (e) {
|
|
794
|
+
return { ok: false, output: `Slack post failed: ${e.message}` };
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function sanitizeProjectName(raw) {
|
|
799
|
+
const base = (raw || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 52);
|
|
800
|
+
return base || 'codeply-project';
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Add/replace KEY=value lines in an .env file without disturbing anything else already in it. */
|
|
804
|
+
function upsertEnvFile(envPath, vars) {
|
|
805
|
+
let existing = '';
|
|
806
|
+
try { existing = fs.readFileSync(envPath, 'utf8'); } catch {}
|
|
807
|
+
const lines = existing.length ? existing.split(/\r?\n/) : [];
|
|
808
|
+
for (const { key, value } of vars) {
|
|
809
|
+
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
810
|
+
const line = `${key}=${value}`;
|
|
811
|
+
if (idx === -1) lines.push(line);
|
|
812
|
+
else lines[idx] = line;
|
|
813
|
+
}
|
|
814
|
+
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
815
|
+
fs.writeFileSync(envPath, `${lines.join('\n')}\n`, 'utf8');
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const VERCEL_MAX_INLINE_BYTES = 15 * 1024 * 1024; // inline base64 deploy body - plenty for a hand-built site, not an asset-heavy monorepo
|
|
819
|
+
const ENV_FILENAME_RE = /^\.env(\..*)?$/;
|
|
820
|
+
|
|
821
|
+
async function vercel_deploy(args, ctx) {
|
|
822
|
+
const root = args.path ? resolvePath(args.path, ctx.cwd).abs : ctx.cwd;
|
|
823
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
824
|
+
return { ok: false, output: `${args.path || '.'} is not a directory.` };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const vercel = config.getIntegration('vercel');
|
|
828
|
+
if (!vercel.accessToken) return { ok: false, output: 'Vercel is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
829
|
+
|
|
830
|
+
// .env files never ship in the deployment bundle - they exist to become
|
|
831
|
+
// Vercel project env vars (see supabase_create_project), not static files a
|
|
832
|
+
// visitor could fetch by URL.
|
|
833
|
+
const paths = walkFiles(root).filter((abs) => !ENV_FILENAME_RE.test(path.basename(abs)));
|
|
834
|
+
if (!paths.length) return { ok: false, output: `No deployable files found under ${path.relative(ctx.cwd, root) || '.'}.` };
|
|
835
|
+
|
|
836
|
+
const files = [];
|
|
837
|
+
let totalBytes = 0;
|
|
838
|
+
for (const abs of paths) {
|
|
839
|
+
const rel = path.relative(root, abs).replace(/\\/g, '/');
|
|
840
|
+
const buf = fs.readFileSync(abs);
|
|
841
|
+
totalBytes += buf.length;
|
|
842
|
+
if (totalBytes > VERCEL_MAX_INLINE_BYTES) {
|
|
843
|
+
return {
|
|
844
|
+
ok: false,
|
|
845
|
+
output: `${path.relative(ctx.cwd, root) || 'This project'} is over the ${VERCEL_MAX_INLINE_BYTES / 1e6}MB inline-deploy limit ` +
|
|
846
|
+
`(stopped at ${rel}). Remove large binary assets from the deploy folder, or deploy a smaller subdirectory.`,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
files.push({ file: rel, data: buf.toString('base64'), encoding: 'base64' });
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const projectName = sanitizeProjectName(path.basename(root));
|
|
853
|
+
|
|
854
|
+
const verdict = await ctx.approve({
|
|
855
|
+
tool: 'vercel_deploy',
|
|
856
|
+
title: `Deploy "${projectName}" to Vercel`,
|
|
857
|
+
detail: `${files.length} file(s), ${(totalBytes / 1024).toFixed(0)}KB - production deployment`,
|
|
858
|
+
danger: true,
|
|
859
|
+
});
|
|
860
|
+
if (verdict === 'reject') return { ok: false, output: `User declined the deploy of ${projectName}.`, meta: { rejected: true } };
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
const deployment = await oauth.vercelDeploy(vercel.accessToken, vercel.teamId, projectName, files);
|
|
864
|
+
// Every project gets `<project-name>.vercel.app` as its default, always-
|
|
865
|
+
// public production domain - separate from (and not listed in) this
|
|
866
|
+
// deployment response's own .url (a per-deploy generated URL with a
|
|
867
|
+
// random hash) or .alias (a team-suffixed alias), both of which stay
|
|
868
|
+
// gated behind Vercel Authentication under this account's protection
|
|
869
|
+
// settings. Reporting either of those hands back a URL that looks broken
|
|
870
|
+
// to anyone without a Vercel login, even though the deploy itself worked.
|
|
871
|
+
const publicUrl = `https://${projectName}.vercel.app`;
|
|
872
|
+
return {
|
|
873
|
+
ok: true,
|
|
874
|
+
output: `Deployed "${projectName}" to Vercel - ${publicUrl} (state: ${deployment.readyState || 'unknown'}). ` +
|
|
875
|
+
`The build runs on Vercel's side - if that state isn't READY yet, it will finish shortly.`,
|
|
876
|
+
meta: { label: projectName },
|
|
877
|
+
};
|
|
878
|
+
} catch (e) {
|
|
879
|
+
return { ok: false, output: `Vercel deploy failed: ${e.message}` };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function supabase_create_project(args, ctx) {
|
|
884
|
+
const supabaseToken = await getValidSupabaseToken();
|
|
885
|
+
if (!supabaseToken) return { ok: false, output: SUPABASE_NOT_CONNECTED };
|
|
886
|
+
|
|
887
|
+
const projectName = sanitizeProjectName(args.name || path.basename(ctx.cwd));
|
|
888
|
+
|
|
889
|
+
let orgs;
|
|
890
|
+
try { orgs = await oauth.supabaseListOrganizations(supabaseToken); }
|
|
891
|
+
catch (e) { return { ok: false, output: `Could not look up Supabase organizations: ${e.message}` }; }
|
|
892
|
+
if (!orgs?.length) return { ok: false, output: 'No Supabase organizations found for this account - create one at supabase.com first.' };
|
|
893
|
+
const org = orgs[0];
|
|
894
|
+
|
|
895
|
+
const vercel = config.getIntegration('vercel');
|
|
896
|
+
const willPushToVercel = !!vercel.accessToken;
|
|
897
|
+
const vercelProjectName = sanitizeProjectName(path.basename(ctx.cwd));
|
|
898
|
+
|
|
899
|
+
const verdict = await ctx.approve({
|
|
900
|
+
tool: 'supabase_create_project',
|
|
901
|
+
title: `Create Supabase project "${projectName}"`,
|
|
902
|
+
detail: `Organization: ${org.name} (${org.slug}). Writes SUPABASE_URL/SUPABASE_ANON_KEY/DATABASE_URL to .env` +
|
|
903
|
+
(willPushToVercel ? ` and pushes the same to the "${vercelProjectName}" Vercel project.` : '.'),
|
|
904
|
+
danger: false,
|
|
905
|
+
});
|
|
906
|
+
if (verdict === 'reject') return { ok: false, output: `User declined creating the Supabase project.`, meta: { rejected: true } };
|
|
907
|
+
|
|
908
|
+
const dbPass = crypto.randomBytes(24).toString('base64url');
|
|
909
|
+
|
|
910
|
+
let project;
|
|
911
|
+
try {
|
|
912
|
+
project = await oauth.supabaseCreateProject(supabaseToken, { name: projectName, organizationSlug: org.slug, dbPass });
|
|
913
|
+
} catch (e) {
|
|
914
|
+
return { ok: false, output: `Supabase project creation failed: ${e.message}` };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
let keys;
|
|
918
|
+
try {
|
|
919
|
+
keys = await oauth.supabaseGetProjectKeys(supabaseToken, project.ref, dbPass);
|
|
920
|
+
} catch (e) {
|
|
921
|
+
return {
|
|
922
|
+
ok: true,
|
|
923
|
+
output: `Created Supabase project "${projectName}" (ref ${project.ref}), but could not fetch its API keys yet: ${e.message} ` +
|
|
924
|
+
`The project exists - check the Supabase dashboard for its URL/anon key.`,
|
|
925
|
+
meta: { label: projectName },
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const envVars = [
|
|
930
|
+
{ key: 'SUPABASE_URL', value: keys.url },
|
|
931
|
+
{ key: 'SUPABASE_ANON_KEY', value: keys.anonKey },
|
|
932
|
+
{ key: 'DATABASE_URL', value: keys.databaseUrl },
|
|
933
|
+
];
|
|
934
|
+
|
|
935
|
+
try {
|
|
936
|
+
upsertEnvFile(path.join(ctx.cwd, '.env'), envVars);
|
|
937
|
+
} catch (e) {
|
|
938
|
+
return {
|
|
939
|
+
ok: true,
|
|
940
|
+
output: `Created Supabase project "${projectName}" (${keys.url}), but could not write .env: ${e.message}\n` +
|
|
941
|
+
envVars.map((v) => `${v.key}=${v.value}`).join('\n'),
|
|
942
|
+
meta: { label: projectName },
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
let vercelNote = '';
|
|
947
|
+
if (willPushToVercel) {
|
|
948
|
+
try {
|
|
949
|
+
await oauth.vercelSetEnvVars(vercel.accessToken, vercel.teamId, vercelProjectName, envVars);
|
|
950
|
+
vercelNote = ` Pushed the same env vars to the "${vercelProjectName}" Vercel project.`;
|
|
951
|
+
} catch (e) {
|
|
952
|
+
vercelNote = ` Wrote .env locally, but pushing env vars to Vercel failed: ${e.message} (Vercel project "${vercelProjectName}" may not exist yet - deploy it first.)`;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return {
|
|
957
|
+
ok: true,
|
|
958
|
+
output: `Created Supabase project "${projectName}" (${keys.url}) and wrote SUPABASE_URL/SUPABASE_ANON_KEY/DATABASE_URL to .env.${vercelNote}`,
|
|
959
|
+
meta: { label: projectName },
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function supabase_delete_project(args, ctx) {
|
|
964
|
+
const supabaseToken = await getValidSupabaseToken();
|
|
965
|
+
if (!supabaseToken) return { ok: false, output: SUPABASE_NOT_CONNECTED };
|
|
966
|
+
|
|
967
|
+
const target = (args.ref || args.name || path.basename(ctx.cwd)).trim();
|
|
968
|
+
if (!target) return { ok: false, output: 'supabase_delete_project needs a <name> or <ref> to identify the project.' };
|
|
969
|
+
|
|
970
|
+
let projects;
|
|
971
|
+
try { projects = await oauth.supabaseListProjects(supabaseToken); }
|
|
972
|
+
catch (e) { return { ok: false, output: `Could not look up Supabase projects: ${e.message}` }; }
|
|
973
|
+
|
|
974
|
+
// A ref (Supabase's project id, e.g. "abcdefghijklmnop") never collides
|
|
975
|
+
// with a name, so try that first before falling back to a name match.
|
|
976
|
+
let project = projects.find((p) => p.ref === target);
|
|
977
|
+
if (!project) {
|
|
978
|
+
const nameLower = sanitizeProjectName(target).toLowerCase();
|
|
979
|
+
const matches = projects.filter((p) => p.name.toLowerCase() === nameLower);
|
|
980
|
+
if (matches.length > 1) {
|
|
981
|
+
return {
|
|
982
|
+
ok: false,
|
|
983
|
+
output: `Multiple Supabase projects are named "${target}" - specify which by <ref> instead: ` +
|
|
984
|
+
matches.map((p) => `${p.ref} (${p.region})`).join(', '),
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
project = matches[0];
|
|
988
|
+
}
|
|
989
|
+
if (!project) return { ok: false, output: `No Supabase project found matching "${target}".` };
|
|
990
|
+
|
|
991
|
+
const verdict = await ctx.approve({
|
|
992
|
+
tool: 'supabase_delete_project',
|
|
993
|
+
title: `Delete Supabase project "${project.name}"`,
|
|
994
|
+
detail: `ref ${project.ref} - this permanently deletes the project and its database. Supabase does not support undo or restore.`,
|
|
995
|
+
danger: true,
|
|
996
|
+
});
|
|
997
|
+
if (verdict === 'reject') return { ok: false, output: `User declined deleting the Supabase project.`, meta: { rejected: true } };
|
|
998
|
+
|
|
999
|
+
try {
|
|
1000
|
+
await oauth.supabaseDeleteProject(supabaseToken, project.ref);
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
return { ok: false, output: `Supabase project deletion failed: ${e.message}` };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
return {
|
|
1006
|
+
ok: true,
|
|
1007
|
+
output: `Deleted Supabase project "${project.name}" (ref ${project.ref}). This did not touch .env or any Vercel env vars pointing at it - remove those separately if they're now stale.`,
|
|
1008
|
+
meta: { label: project.name },
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** GitHub repo names allow more than Vercel/Supabase project names - letters (any case), digits, dot/dash/underscore. */
|
|
1013
|
+
function sanitizeRepoName(raw) {
|
|
1014
|
+
const base = (raw || '').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
1015
|
+
return base || 'codeply-project';
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Async on purpose, same as run()'s exec() below - this is the ONE thing
|
|
1019
|
+
// every concurrently-running session shares (Electron's single main-process
|
|
1020
|
+
// event loop), so a synchronous git call (execSync) here doesn't just block
|
|
1021
|
+
// its own turn, it freezes every other session - the main chat included -
|
|
1022
|
+
// for as long as it runs. That's harmless for a local `git init`/`commit`
|
|
1023
|
+
// (milliseconds) but genuinely dangerous for `git push`, a real network
|
|
1024
|
+
// call that can take seconds to tens of seconds: a synchronous push used to
|
|
1025
|
+
// hang the entire app solid until it finished.
|
|
1026
|
+
function execAsync(cmd, opts) {
|
|
1027
|
+
return new Promise((resolve, reject) => {
|
|
1028
|
+
exec(cmd, opts, (err, stdout) => {
|
|
1029
|
+
if (err) { reject(err); return; }
|
|
1030
|
+
resolve(stdout);
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function runGit(args, cwd) {
|
|
1036
|
+
return execAsync(`git ${args}`, { cwd, windowsHide: true, encoding: 'utf8', timeout: RUN_TIMEOUT_MS });
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function github_create_repo(args, ctx) {
|
|
1040
|
+
const root = args.path ? resolvePath(args.path, ctx.cwd).abs : ctx.cwd;
|
|
1041
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
1042
|
+
return { ok: false, output: `${args.path || '.'} is not a directory.` };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const github = config.getIntegration('github');
|
|
1046
|
+
if (!github.accessToken) return { ok: false, output: 'GitHub is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
1047
|
+
|
|
1048
|
+
const repoName = sanitizeRepoName(args.name || path.basename(root));
|
|
1049
|
+
|
|
1050
|
+
const verdict = await ctx.approve({
|
|
1051
|
+
tool: 'github_create_repo',
|
|
1052
|
+
title: `Create GitHub repo "${repoName}" and push`,
|
|
1053
|
+
detail: `Creates a new private repo under ${github.userName || 'your account'} from ${path.relative(ctx.cwd, root) || '.'} and pushes it - no need to create the repo on github.com first.`,
|
|
1054
|
+
danger: false,
|
|
1055
|
+
});
|
|
1056
|
+
if (verdict === 'reject') return { ok: false, output: 'User declined creating the GitHub repo.', meta: { rejected: true } };
|
|
1057
|
+
|
|
1058
|
+
try {
|
|
1059
|
+
// Turn the folder into a repo if it isn't one yet, and make sure it has
|
|
1060
|
+
// at least one commit - a brand-new GitHub repo (created with no
|
|
1061
|
+
// auto_init below) starts completely empty, so pushing an empty local
|
|
1062
|
+
// repo would just fail with "src refspec main does not match any".
|
|
1063
|
+
const isRepo = fs.existsSync(path.join(root, '.git'));
|
|
1064
|
+
if (!isRepo) await runGit('init', root);
|
|
1065
|
+
let hasCommit = true;
|
|
1066
|
+
try { await runGit('rev-parse HEAD', root); } catch { hasCommit = false; }
|
|
1067
|
+
if (!hasCommit) {
|
|
1068
|
+
await runGit('add -A', root);
|
|
1069
|
+
await runGit('commit -m "Initial commit"', root);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const repo = await oauth.githubCreateRepo(github.accessToken, repoName, { private: true });
|
|
1073
|
+
|
|
1074
|
+
// The access token rides the remote URL only for this one push, then
|
|
1075
|
+
// gets scrubbed back out - .git/config is plaintext on disk, and leaving
|
|
1076
|
+
// a live token sitting in it is a real credential leak, not a theoretical
|
|
1077
|
+
// one. Future pushes from the user's own terminal go through their normal
|
|
1078
|
+
// Git credential helper against the clean URL instead.
|
|
1079
|
+
const cleanUrl = repo.clone_url;
|
|
1080
|
+
const tokenUrl = cleanUrl.replace('https://', `https://x-access-token:${github.accessToken}@`);
|
|
1081
|
+
let hasOrigin = true;
|
|
1082
|
+
try { await runGit('remote get-url origin', root); } catch { hasOrigin = false; }
|
|
1083
|
+
await runGit(`remote ${hasOrigin ? 'set-url' : 'add'} origin ${tokenUrl}`, root);
|
|
1084
|
+
|
|
1085
|
+
await runGit('branch -M main', root);
|
|
1086
|
+
try {
|
|
1087
|
+
await runGit('push -u origin main', root);
|
|
1088
|
+
} finally {
|
|
1089
|
+
await runGit(`remote set-url origin ${cleanUrl}`, root);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return {
|
|
1093
|
+
ok: true,
|
|
1094
|
+
output: `Created ${repo.full_name} (private) and pushed ${path.relative(ctx.cwd, root) || '.'} to it - ${repo.html_url}`,
|
|
1095
|
+
meta: { label: repo.full_name },
|
|
1096
|
+
};
|
|
1097
|
+
} catch (e) {
|
|
1098
|
+
return { ok: false, output: `GitHub repo creation/push failed: ${e.message}` };
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const BROWSER_CHECK_DEFAULT_WAIT_MS = 700;
|
|
1103
|
+
const BROWSER_CHECK_MAX_WAIT_MS = 5000;
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Opens a page in a real browser and reports what actually happened -
|
|
1107
|
+
* console errors/warnings, failed requests, broken images, visible text.
|
|
1108
|
+
* Read-only (no ctx.approve - nothing on disk or on the network changes) and
|
|
1109
|
+
* not always available: it depends on ctx.browser, a function only a host
|
|
1110
|
+
* with a real embedded browser provides (the Codeply Craft desktop app).
|
|
1111
|
+
* The plain terminal CLI has no browser to hand it, so ctx.browser is simply
|
|
1112
|
+
* absent there and this reports itself unavailable rather than throwing.
|
|
1113
|
+
*/
|
|
1114
|
+
async function browser_check(args, ctx) {
|
|
1115
|
+
const url = (args.url || '').trim();
|
|
1116
|
+
if (!url) return { ok: false, output: 'browser_check needs a <url>.' };
|
|
1117
|
+
|
|
1118
|
+
let parsed;
|
|
1119
|
+
try { parsed = new URL(url); }
|
|
1120
|
+
catch { return { ok: false, output: `Not a valid URL: ${url}. Use file:///<absolute path> for a local file, or http://localhost:<port>/... for a running server.` }; }
|
|
1121
|
+
if (!['http:', 'https:', 'file:'].includes(parsed.protocol)) {
|
|
1122
|
+
return { ok: false, output: 'browser_check only supports http://, https://, and file:// URLs.' };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (!ctx.browser) {
|
|
1126
|
+
return {
|
|
1127
|
+
ok: false,
|
|
1128
|
+
output: 'browser_check is not available here - this environment has no embedded browser to check with ' +
|
|
1129
|
+
'(that capability is only provided by the Codeply Craft desktop app, not the terminal CLI). ' +
|
|
1130
|
+
'Continue by reasoning from the source instead; do not retry this.',
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const wait = Math.min(BROWSER_CHECK_MAX_WAIT_MS, Math.max(0, parseInt(args.wait, 10) || BROWSER_CHECK_DEFAULT_WAIT_MS));
|
|
1135
|
+
|
|
1136
|
+
const viewport = String(args.viewport || 'desktop').trim().toLowerCase();
|
|
1137
|
+
|
|
1138
|
+
let report;
|
|
1139
|
+
try {
|
|
1140
|
+
report = await ctx.browser(url, { wait, viewport });
|
|
1141
|
+
} catch (e) {
|
|
1142
|
+
return { ok: false, output: `browser_check failed: ${e.message}` };
|
|
1143
|
+
}
|
|
1144
|
+
if (!report || !report.ok) {
|
|
1145
|
+
return { ok: false, output: `Could not load ${url}: ${(report && report.error) || 'unknown error'}` };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const lines = [`Loaded ${url}`, `Title: ${report.title || '(none)'}`, `Viewport: ${report.viewport || 'desktop'}`];
|
|
1149
|
+
if (report.overflowX) {
|
|
1150
|
+
lines.push('', `NOT RESPONSIVE: the page is ${report.pageWidth}px wide in a ${report.viewportWidth}px viewport, so it scrolls sideways.` +
|
|
1151
|
+
(report.wideElements?.length ? ` Elements past the right edge: ${report.wideElements.join(', ')}.` : ''));
|
|
1152
|
+
}
|
|
1153
|
+
if (report.viewport && report.viewport !== 'desktop' && report.hasViewportMeta === false) {
|
|
1154
|
+
lines.push('', 'Missing <meta name="viewport" content="width=device-width, initial-scale=1">: phones will render the desktop layout zoomed out.');
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (report.consoleErrors?.length) {
|
|
1158
|
+
lines.push('', `Console errors (${report.consoleErrors.length}):`, ...report.consoleErrors.slice(0, 30).map((m) => ` ${m}`));
|
|
1159
|
+
} else {
|
|
1160
|
+
lines.push('', 'Console errors: none');
|
|
1161
|
+
}
|
|
1162
|
+
if (report.consoleWarnings?.length) {
|
|
1163
|
+
lines.push('', `Console warnings (${report.consoleWarnings.length}):`, ...report.consoleWarnings.slice(0, 15).map((m) => ` ${m}`));
|
|
1164
|
+
}
|
|
1165
|
+
if (report.failedRequests?.length) {
|
|
1166
|
+
lines.push('', `Failed network requests (${report.failedRequests.length}):`, ...report.failedRequests.slice(0, 20).map((m) => ` ${m}`));
|
|
1167
|
+
}
|
|
1168
|
+
if (report.brokenImages?.length) {
|
|
1169
|
+
lines.push('', `Broken images (${report.brokenImages.length}):`, ...report.brokenImages.slice(0, 20).map((m) => ` ${m}`));
|
|
1170
|
+
}
|
|
1171
|
+
lines.push('', 'Visible page text (truncated):', report.text ? report.text.slice(0, 1500) : '(empty page)');
|
|
1172
|
+
if (report.screenshotPath) lines.push('', `Screenshot saved to disk: ${report.screenshotPath}`);
|
|
1173
|
+
if (report.screenshotDataUrl) lines.push('', 'A screenshot of the actual rendered page is attached to this result - look at it before judging whether the page is correct, not just the text above.');
|
|
1174
|
+
|
|
1175
|
+
const clean = !(report.consoleErrors?.length || report.failedRequests?.length || report.brokenImages?.length || report.overflowX);
|
|
1176
|
+
|
|
1177
|
+
return {
|
|
1178
|
+
ok: true,
|
|
1179
|
+
output: truncate(lines.join('\n')),
|
|
1180
|
+
// screenshotDataUrl rides in meta, not output: agent.mjs turns it into a
|
|
1181
|
+
// real image_url content part on the tool-result message (the same shape
|
|
1182
|
+
// already used for pasted user images) so the model actually sees the
|
|
1183
|
+
// page instead of only reading a text description of it.
|
|
1184
|
+
meta: {
|
|
1185
|
+
label: report.viewport && report.viewport !== 'desktop' ? `${url} · ${report.viewport.split(' ')[0]}` : url,
|
|
1186
|
+
clean, errorCount: report.consoleErrors?.length || 0, overflowX: !!report.overflowX,
|
|
1187
|
+
screenshotDataUrl: report.screenshotDataUrl || null,
|
|
1188
|
+
// Persisted on the session message so a screenshot survives reopening
|
|
1189
|
+
// the chat later - the data: URL above is only ever sent over the
|
|
1190
|
+
// live event stream, never written to the session store (it'd bloat
|
|
1191
|
+
// every saved chat by hundreds of KB per check).
|
|
1192
|
+
screenshotPath: report.screenshotPath || null,
|
|
1193
|
+
},
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const CODEPLY_COMMIT_EMAIL = 'noreply.codeplyai@gmail.com';
|
|
1198
|
+
const GIT_COMMIT_RE = /\bgit\s+commit\b/;
|
|
1199
|
+
|
|
1200
|
+
// Cached after the first run() call - a version check on every single command
|
|
1201
|
+
// would be wasted work, and the answer can't change mid-session. Cached as a
|
|
1202
|
+
// Promise (not the resolved boolean) so two overlapping first calls share the
|
|
1203
|
+
// same in-flight check instead of both firing `git --version`.
|
|
1204
|
+
let gitTrailerCheck = null;
|
|
1205
|
+
function checkGitTrailerSupport() {
|
|
1206
|
+
if (!gitTrailerCheck) {
|
|
1207
|
+
gitTrailerCheck = execAsync('git --version', { windowsHide: true, timeout: 3000 })
|
|
1208
|
+
.then((out) => {
|
|
1209
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
|
|
1210
|
+
return m ? (Number(m[1]) > 2 || (Number(m[1]) === 2 && Number(m[2]) >= 32)) : false;
|
|
1211
|
+
})
|
|
1212
|
+
.catch(() => false);
|
|
1213
|
+
}
|
|
1214
|
+
return gitTrailerCheck;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Every commit the agent makes gets credited to the shared Codeply GitHub
|
|
1219
|
+
* account (CodeplyAI, noreply.codeplyai@gmail.com verified on it) via git's
|
|
1220
|
+
* built-in --trailer flag (git >= 2.32), so it shows up in GitHub's commit/PR
|
|
1221
|
+
* view as a linked contributor with the Codeply avatar - the same mechanism
|
|
1222
|
+
* Claude Code uses for its own "Co-Authored-By: Claude" commits. This has to
|
|
1223
|
+
* happen here, not by asking the model to remember to type the trailer
|
|
1224
|
+
* itself: --trailer works no matter how the model wrote the commit (-m, an
|
|
1225
|
+
* editor, multiple -m flags), and doing it centrally means it's never missed.
|
|
1226
|
+
* Skipped silently on older git (no --trailer support, checked once above)
|
|
1227
|
+
* or if the model already wrote its own Co-authored-by line, rather than
|
|
1228
|
+
* risk breaking every single commit over a flag an old git doesn't recognize.
|
|
1229
|
+
*/
|
|
1230
|
+
async function withCodeplyTrailer(command) {
|
|
1231
|
+
if (!GIT_COMMIT_RE.test(command) || /co-authored-by/i.test(command) || !(await checkGitTrailerSupport())) return command;
|
|
1232
|
+
return command.replace(GIT_COMMIT_RE, `git commit --trailer "Co-authored-by=Codeply <${CODEPLY_COMMIT_EMAIL}>"`);
|
|
299
1233
|
}
|
|
300
1234
|
|
|
301
1235
|
async function run(args, ctx) {
|
|
302
|
-
|
|
303
|
-
|
|
1236
|
+
// displayCommand is what the user sees - the approval card, the result
|
|
1237
|
+
// label, the echoed "$ ..." line - and stays exactly what the model wrote.
|
|
1238
|
+
// execCommand is what actually runs, with the Codeply co-author trailer
|
|
1239
|
+
// spliced into any git commit. Keeping them separate means the contributor
|
|
1240
|
+
// bookkeeping is invisible: the user approves "git add . && git commit -m
|
|
1241
|
+
// '...' && git push" and that's the only thing that ever appears anywhere
|
|
1242
|
+
// in the UI, even though the real command run on disk carries the trailer.
|
|
1243
|
+
const displayCommand = (args.command || '').trim();
|
|
1244
|
+
if (!displayCommand) return { ok: false, output: 'run needs a <command>.' };
|
|
1245
|
+
const execCommand = await withCodeplyTrailer(displayCommand);
|
|
304
1246
|
|
|
305
1247
|
const verdict = await ctx.approve({
|
|
306
1248
|
tool: 'run',
|
|
307
1249
|
title: 'Run command',
|
|
308
|
-
detail:
|
|
309
|
-
|
|
1250
|
+
detail: displayCommand,
|
|
1251
|
+
// What "always allow" covers: these command names, not every command.
|
|
1252
|
+
patterns: commandPatterns(displayCommand),
|
|
1253
|
+
danger: /\brm\s+-rf\b|\bdel\s+\/[sf]\b|format\s|mkfs|>\s*\/dev\/sd|shutdown|reboot|:\(\)\{|curl[^|]*\|\s*(ba)?sh/i.test(displayCommand),
|
|
310
1254
|
});
|
|
311
|
-
if (verdict === 'reject') return { ok: false, output: `User declined to run: ${
|
|
1255
|
+
if (verdict === 'reject') return { ok: false, output: `User declined to run: ${displayCommand}`, meta: { rejected: true } };
|
|
312
1256
|
|
|
313
1257
|
return new Promise((resolve) => {
|
|
314
|
-
exec(
|
|
1258
|
+
exec(execCommand, { cwd: ctx.cwd, timeout: RUN_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
|
|
315
1259
|
(err, stdout, stderr) => {
|
|
316
1260
|
const code = err ? (err.code == null ? 1 : err.code) : 0;
|
|
317
1261
|
const parts = [];
|
|
@@ -319,53 +1263,745 @@ async function run(args, ctx) {
|
|
|
319
1263
|
if (stderr && stderr.trim()) parts.push(`[stderr]\n${stderr.trimEnd()}`);
|
|
320
1264
|
if (err && err.killed) parts.push(`[timed out after ${RUN_TIMEOUT_MS / 1000}s]`);
|
|
321
1265
|
const body = parts.join('\n') || '(no output)';
|
|
1266
|
+
// Hints are computed on the untruncated output but appended after
|
|
1267
|
+
// truncation, so they are never the part that gets cut off.
|
|
1268
|
+
const combined = `${stdout || ''}\n${stderr || ''}`;
|
|
1269
|
+
const hint = code ? terminalHints.annotateFailure(displayCommand, code, combined)
|
|
1270
|
+
: terminalHints.annotateMaskedSuccess(displayCommand, combined);
|
|
1271
|
+
// A masked failure is still reported as failed to the loop, so the
|
|
1272
|
+
// "last command failed" and verification checks treat it as one.
|
|
1273
|
+
const effectiveCode = !code && hint ? 1 : code;
|
|
322
1274
|
resolve({
|
|
323
1275
|
ok: true, // a non-zero exit is a real result the model must see, not a tool failure
|
|
324
|
-
output: truncate(`$ ${
|
|
325
|
-
meta: { label:
|
|
1276
|
+
output: truncate(`$ ${displayCommand}\n[exit ${code}]\n${body}`, MAX_TOOL_OUTPUT, { keep: 'ends' }) + (hint ? `\n[hint] ${hint}` : ''),
|
|
1277
|
+
meta: { label: displayCommand, exitCode: effectiveCode, ...(effectiveCode !== code ? { maskedFailure: true } : {}) },
|
|
326
1278
|
});
|
|
327
1279
|
});
|
|
328
1280
|
});
|
|
329
1281
|
}
|
|
330
1282
|
|
|
331
|
-
|
|
1283
|
+
/**
|
|
1284
|
+
* Load one skill's full instructions on demand. Read-only, no approval - the
|
|
1285
|
+
* whole point of the skill index living in the system prompt (see
|
|
1286
|
+
* lib/skills.js) is that only a name and a one-line description cost anything
|
|
1287
|
+
* until the agent actually decides a skill is relevant, at which point this is
|
|
1288
|
+
* the one call that pulls in the rest.
|
|
1289
|
+
*/
|
|
1290
|
+
async function use_skill(args, ctx) {
|
|
1291
|
+
const name = (args.name || '').trim();
|
|
1292
|
+
if (!name) return { ok: false, output: 'use_skill needs a <name>.' };
|
|
1293
|
+
|
|
1294
|
+
const body = skills.loadSkillBody(name, ctx.cwd);
|
|
1295
|
+
if (body == null) {
|
|
1296
|
+
// Capped rather than dumping the full library (281 names) into a reply
|
|
1297
|
+
// that only exists because of one wrong guess.
|
|
1298
|
+
const all = skills.listSkills(ctx.cwd).map((s) => s.name);
|
|
1299
|
+
const shown = all.slice(0, 40).join(', ') + (all.length > 40 ? `, … (${all.length - 40} more - see list_skills)` : '');
|
|
1300
|
+
return {
|
|
1301
|
+
ok: false,
|
|
1302
|
+
output: `No skill named "${name}". ${all.length ? `Some available: ${shown}` : '(none installed)'}`,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
return { ok: true, output: body, meta: { label: name } };
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* The full skill catalog - the counterpart to the DAILY-only index that
|
|
1310
|
+
* always rides in the system prompt (see lib/skills.js). One call, paid once,
|
|
1311
|
+
* only when the task actually needs something outside that curated set.
|
|
1312
|
+
*
|
|
1313
|
+
* At 281 skills, name+description together run past MAX_TOOL_OUTPUT - an
|
|
1314
|
+
* optional query narrows to matching names/descriptions before formatting, so
|
|
1315
|
+
* the normal case (looking for "something about X") returns a handful of
|
|
1316
|
+
* relevant lines instead of a wall of text truncated at an arbitrary,
|
|
1317
|
+
* alphabetically-biased cutoff.
|
|
1318
|
+
*/
|
|
1319
|
+
async function list_skills(args, ctx) {
|
|
1320
|
+
const all = skills.listSkills(ctx.cwd);
|
|
1321
|
+
const query = (args.query || '').trim().toLowerCase();
|
|
1322
|
+
const matched = query
|
|
1323
|
+
? all.filter((s) => s.name.toLowerCase().includes(query) || s.description.toLowerCase().includes(query))
|
|
1324
|
+
: all;
|
|
1325
|
+
|
|
1326
|
+
if (query && matched.length === 0) {
|
|
1327
|
+
return { ok: true, output: `No skills matched "${query}" out of ${all.length} total.`, meta: { count: 0 } };
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const index = skills.formatSkillIndex(matched, { all: true });
|
|
1331
|
+
const header = query ? `${matched.length} of ${all.length} skills match "${query}":\n` : `${all.length} skills:\n`;
|
|
1332
|
+
return {
|
|
1333
|
+
ok: true,
|
|
1334
|
+
output: truncate(header + (index || '(none)')),
|
|
1335
|
+
meta: { label: query || 'all skills', count: matched.length },
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Real-app UI reference search against the private design library built
|
|
1341
|
+
* locally in lib/design-library/index.json (914 apps / 6,433 real App Store
|
|
1342
|
+
* screenshots, 10 categories, sourced from Apple's public iTunes Search
|
|
1343
|
+
* API). This is the required design-reference step for any UI work - it
|
|
1344
|
+
* doesn't depend on any external service being reachable or authenticated,
|
|
1345
|
+
* since this data lives on disk. Read-only, no ctx.approve().
|
|
1346
|
+
*/
|
|
1347
|
+
async function design_reference_search(args, ctx) {
|
|
1348
|
+
const term = (args.term || '').trim();
|
|
1349
|
+
const category = (args.category || '').trim();
|
|
1350
|
+
if (!term && !category) {
|
|
1351
|
+
return {
|
|
1352
|
+
ok: false,
|
|
1353
|
+
output: `design_reference_search needs a term and/or category. Categories: ${Object.keys(CATEGORY_LABELS).join(', ')}.`,
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
if (!designLibraryConfigured()) {
|
|
1357
|
+
return { ok: false, output: 'The design reference library is not built on this machine (index.json missing). Say so plainly rather than inventing a reference.' };
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const results = searchLibrary({ term, category });
|
|
1361
|
+
if (!results.length) {
|
|
1362
|
+
const cats = listCategories().map((c) => `${c.label} (${c.appCount})`).join(', ');
|
|
1363
|
+
return {
|
|
1364
|
+
ok: true,
|
|
1365
|
+
output: `No matches for "${term || category}". Available categories: ${cats}.`,
|
|
1366
|
+
meta: { label: term || category },
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const lines = results.map((r, i) =>
|
|
1371
|
+
`${i + 1}. ${r.name} - ${r.categoryLabel}${r.rating ? ` (${r.rating}★)` : ''}\n` +
|
|
1372
|
+
r.screenshots.map((url) => ` screenshot: ${url}`).join('\n'));
|
|
1373
|
+
|
|
1374
|
+
const fallbackNote = results.usedFallback
|
|
1375
|
+
? `No app is literally named "${term}" - this library only indexes app names, not per-screen content, so a pattern-style ` +
|
|
1376
|
+
`term rarely matches directly. These are the top-rated ${category} apps instead; browse their screenshots for the ` +
|
|
1377
|
+
`specific screen type you need.\n\n`
|
|
1378
|
+
: '';
|
|
1379
|
+
|
|
1380
|
+
return {
|
|
1381
|
+
ok: true,
|
|
1382
|
+
output: `${fallbackNote}${results.length} app(s) matching "${term || category}":\n\n${lines.join('\n')}\n\n` +
|
|
1383
|
+
`MANDATORY NEXT STEP: these are just URLs, not a design brief - you have not "used" this reference yet, and ` +
|
|
1384
|
+
`fetch_image will NOT show you what they look like (it only downloads to disk, silently, no vision). ` +
|
|
1385
|
+
`Before writing any markup, call view_images with 2-4 of the "screenshot:" URLs above (pick from different apps, ` +
|
|
1386
|
+
`not all from #1) - that is the only way to actually see the real layout structure, spacing, type scale, color ` +
|
|
1387
|
+
`choices, and component patterns those apps ship with. Build FROM what you saw, not from a generic idea of what ` +
|
|
1388
|
+
`that kind of app "usually" looks like. If you skip view_images and design from memory instead, do not tell the ` +
|
|
1389
|
+
`user you referenced real apps - say plainly that you designed from your own judgment this time.`,
|
|
1390
|
+
meta: { label: term || category, count: results.length },
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const MAX_VIEW_IMAGES = 4;
|
|
1395
|
+
const MAX_VIEW_IMAGES_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Fetch 1-4 image URLs and hand them back as real vision content on the
|
|
1399
|
+
* tool-result message (same image_url content-part mechanism browser_check
|
|
1400
|
+
* uses for its screenshot) - nothing is written to disk, nothing on the
|
|
1401
|
+
* user's machine changes, so this is read-only like
|
|
1402
|
+
* design_reference_search, not fetch_image (which downloads an asset to a
|
|
1403
|
+
* path and deliberately does NOT attach it as vision, since most fetch_image
|
|
1404
|
+
* calls are just pulling a hero photo, not something worth spending a vision
|
|
1405
|
+
* pass on). This exists specifically so a reference-search result - a list
|
|
1406
|
+
* of screenshot URLs - can actually be looked at instead of just cited.
|
|
1407
|
+
*/
|
|
1408
|
+
async function view_images(args, ctx) {
|
|
1409
|
+
const raw = (args.urls || '').trim();
|
|
1410
|
+
if (!raw) return { ok: false, output: 'view_images needs <urls> - one or more image URLs, comma-separated.' };
|
|
1411
|
+
const urls = raw.split(',').map((u) => u.trim()).filter(Boolean).slice(0, MAX_VIEW_IMAGES);
|
|
1412
|
+
if (!urls.length) return { ok: false, output: 'No valid URLs in <urls>.' };
|
|
1413
|
+
|
|
1414
|
+
const dataUrls = [];
|
|
1415
|
+
const failed = [];
|
|
1416
|
+
let totalBytes = 0;
|
|
1417
|
+
|
|
1418
|
+
for (const url of urls) {
|
|
1419
|
+
let parsed;
|
|
1420
|
+
try { parsed = new URL(url); }
|
|
1421
|
+
catch { failed.push(`${url} - not a valid URL`); continue; }
|
|
1422
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
1423
|
+
failed.push(`${url} - only http/https supported`);
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
try {
|
|
1427
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'follow' });
|
|
1428
|
+
if (!res.ok) { failed.push(`${url} - HTTP ${res.status}`); continue; }
|
|
1429
|
+
const contentType = (res.headers.get('content-type') || '').split(';')[0].trim();
|
|
1430
|
+
if (!contentType.startsWith('image/')) { failed.push(`${url} - not an image (${contentType || 'unknown'})`); continue; }
|
|
1431
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1432
|
+
if (totalBytes + buf.length > MAX_VIEW_IMAGES_TOTAL_BYTES) { failed.push(`${url} - skipped, combined size limit reached`); continue; }
|
|
1433
|
+
totalBytes += buf.length;
|
|
1434
|
+
dataUrls.push(`data:${contentType};base64,${buf.toString('base64')}`);
|
|
1435
|
+
} catch (e) {
|
|
1436
|
+
failed.push(`${url} - ${e.message}`);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
if (!dataUrls.length) {
|
|
1441
|
+
return { ok: false, output: `Could not load any of the requested images:\n${failed.join('\n')}` };
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const lines = [`${dataUrls.length} image(s) attached below - look at them before continuing.`];
|
|
1445
|
+
if (failed.length) lines.push('', `${failed.length} failed:`, ...failed.map((f) => ` ${f}`));
|
|
1446
|
+
|
|
1447
|
+
return {
|
|
1448
|
+
ok: true,
|
|
1449
|
+
output: lines.join('\n'),
|
|
1450
|
+
meta: { label: `${dataUrls.length} image(s)`, imageDataUrls: dataUrls },
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// ─── Full-access Supabase / Vercel ──────────────────────────────────────────
|
|
1455
|
+
// Once the user connects Supabase or Vercel, these give the agent the whole
|
|
1456
|
+
// Management/REST API rather than a handful of canned operations. Reads (GET,
|
|
1457
|
+
// read-only SQL) run without a prompt; anything that changes something goes
|
|
1458
|
+
// through the normal approval card, flagged as dangerous for deletes.
|
|
1459
|
+
|
|
1460
|
+
const API_OUTPUT_MAX = 10000;
|
|
1461
|
+
const READ_ONLY_MODES = new Set(['Plan', 'Ask']);
|
|
1462
|
+
|
|
1463
|
+
function normalizeMethod(raw) {
|
|
1464
|
+
const m = String(raw || 'GET').trim().toUpperCase();
|
|
1465
|
+
return ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(m) ? m : null;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function parseJsonBody(raw) {
|
|
1469
|
+
const text = String(raw || '').trim();
|
|
1470
|
+
if (!text) return { ok: true, value: undefined };
|
|
1471
|
+
try { return { ok: true, value: JSON.parse(text) }; }
|
|
1472
|
+
catch (e) { return { ok: false, error: `The <body> is not valid JSON (${e.message}). Send a JSON object.` }; }
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function formatApiResult(service, method, apiPath, r) {
|
|
1476
|
+
const shown = typeof r.body === 'string' ? r.body : JSON.stringify(r.body, null, 2);
|
|
1477
|
+
const status = `${service} ${method} ${apiPath} → HTTP ${r.status}${r.ok ? '' : ' (FAILED)'}`;
|
|
1478
|
+
return truncate(`${status}\n${shown ?? '(empty body)'}`, API_OUTPUT_MAX);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Supabase OAuth tokens expire. Refreshed shortly before expiry using the app's
|
|
1483
|
+
* OAuth credentials (config.getIntegration fills those from the environment).
|
|
1484
|
+
*/
|
|
1485
|
+
async function getValidSupabaseToken() {
|
|
1486
|
+
const sb = config.getIntegration('supabase');
|
|
1487
|
+
if (!sb.accessToken) return null;
|
|
1488
|
+
if (!sb.expiresAt || Date.now() < sb.expiresAt - 120000) return sb.accessToken;
|
|
1489
|
+
if (!sb.refreshToken || !sb.clientId || !sb.clientSecret) return sb.accessToken;
|
|
1490
|
+
try {
|
|
1491
|
+
const t = await oauth.refreshSupabaseToken(sb.clientId, sb.clientSecret, sb.refreshToken);
|
|
1492
|
+
config.saveIntegration('supabase', {
|
|
1493
|
+
accessToken: t.access_token,
|
|
1494
|
+
refreshToken: t.refresh_token || sb.refreshToken,
|
|
1495
|
+
expiresAt: Date.now() + (t.expires_in || 3600) * 1000,
|
|
1496
|
+
});
|
|
1497
|
+
return t.access_token;
|
|
1498
|
+
} catch {
|
|
1499
|
+
return sb.accessToken; // let the call itself report the auth failure
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
const SUPABASE_NOT_CONNECTED = 'Supabase is not connected. Tell the user to connect it from Connect Apps (account menu → Connect Apps → Supabase). Do not work around it with the CLI.';
|
|
1504
|
+
const VERCEL_NOT_CONNECTED = 'Vercel is not connected. Tell the user to connect it from Connect Apps (account menu → Connect Apps → Vercel). Do not work around it with the CLI.';
|
|
1505
|
+
|
|
1506
|
+
async function supabase_api(args, ctx) {
|
|
1507
|
+
const method = normalizeMethod(args.method);
|
|
1508
|
+
if (!method) return { ok: false, output: 'supabase_api <method> must be GET, POST, PUT, PATCH or DELETE.' };
|
|
1509
|
+
const apiPath = String(args.path || '').trim();
|
|
1510
|
+
if (!apiPath.startsWith('/v1/')) return { ok: false, output: 'supabase_api <path> must start with /v1/ (e.g. /v1/projects, /v1/projects/{ref}/functions).' };
|
|
1511
|
+
const body = parseJsonBody(args.body);
|
|
1512
|
+
if (!body.ok) return { ok: false, output: body.error };
|
|
1513
|
+
if (method !== 'GET' && READ_ONLY_MODES.has(ctx.mode)) return { ok: false, output: `Blocked - ${ctx.mode} mode is read-only; only GET requests are allowed.` };
|
|
1514
|
+
|
|
1515
|
+
const token = await getValidSupabaseToken();
|
|
1516
|
+
if (!token) return { ok: false, output: SUPABASE_NOT_CONNECTED };
|
|
1517
|
+
|
|
1518
|
+
if (method !== 'GET') {
|
|
1519
|
+
const verdict = await ctx.approve({
|
|
1520
|
+
tool: 'supabase_api',
|
|
1521
|
+
title: `Supabase ${method} ${apiPath}`,
|
|
1522
|
+
detail: body.value !== undefined ? JSON.stringify(body.value, null, 2).slice(0, 2000) : '(no body)',
|
|
1523
|
+
danger: method === 'DELETE',
|
|
1524
|
+
});
|
|
1525
|
+
if (verdict === 'reject') return { ok: false, output: `User declined the Supabase ${method} ${apiPath} request.`, meta: { rejected: true } };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
let r;
|
|
1529
|
+
try { r = await oauth.supabaseApi(token, method, apiPath, body.value); }
|
|
1530
|
+
catch (e) { return { ok: false, output: `Supabase request failed: ${e.message}` }; }
|
|
1531
|
+
return { ok: r.ok, output: formatApiResult('Supabase', method, apiPath, r), meta: { label: `${method} ${apiPath}`, status: r.status } };
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// A SELECT can still change things (SELECT INTO, or calling a function with
|
|
1535
|
+
// side effects), so only plain reads using known-safe functions skip approval.
|
|
1536
|
+
const SAFE_SQL_FUNCS = new Set(['count', 'sum', 'avg', 'min', 'max', 'coalesce', 'nullif', 'greatest', 'least', 'lower', 'upper',
|
|
1537
|
+
'length', 'trim', 'round', 'abs', 'now', 'cast', 'to_char', 'date_trunc', 'array_agg', 'string_agg', 'json_agg', 'jsonb_agg',
|
|
1538
|
+
'row_number', 'rank', 'exists', 'in', 'any', 'all', 'values', 'over', 'filter', 'format_type', 'pg_get_expr', 'obj_description',
|
|
1539
|
+
'col_description', 'pg_size_pretty', 'pg_total_relation_size', 'pg_relation_size', 'current_setting', 'as', 'and', 'or', 'not', 'on', 'using']);
|
|
1540
|
+
function isReadOnlySql(q) {
|
|
1541
|
+
if (!/^\s*(select|with|explain|show)\b/i.test(q)) return false;
|
|
1542
|
+
if (/\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|comment|vacuum|reindex|copy|into|call|do|lock|set|notify|listen)\b/i.test(q)) return false;
|
|
1543
|
+
if (q.includes(';') && q.trim().replace(/;\s*$/, '').includes(';')) return false; // multiple statements
|
|
1544
|
+
for (const m of q.matchAll(/\b([a-z_][a-z0-9_]*)\s*\(/gi)) {
|
|
1545
|
+
if (!SAFE_SQL_FUNCS.has(m[1].toLowerCase())) return false;
|
|
1546
|
+
}
|
|
1547
|
+
return true;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
async function supabase_sql(args, ctx) {
|
|
1551
|
+
const ref = String(args.ref || '').trim();
|
|
1552
|
+
const query = String(args.query || '').trim();
|
|
1553
|
+
if (!ref) return { ok: false, output: 'supabase_sql needs a <ref> (the project ref - list projects with supabase_api GET /v1/projects).' };
|
|
1554
|
+
if (!query) return { ok: false, output: 'supabase_sql needs a <query>.' };
|
|
1555
|
+
const readOnly = isReadOnlySql(query);
|
|
1556
|
+
if (!readOnly && READ_ONLY_MODES.has(ctx.mode)) return { ok: false, output: `Blocked - ${ctx.mode} mode is read-only; only SELECT queries are allowed.` };
|
|
1557
|
+
|
|
1558
|
+
const token = await getValidSupabaseToken();
|
|
1559
|
+
if (!token) return { ok: false, output: SUPABASE_NOT_CONNECTED };
|
|
1560
|
+
|
|
1561
|
+
if (!readOnly) {
|
|
1562
|
+
const verdict = await ctx.approve({
|
|
1563
|
+
tool: 'supabase_sql',
|
|
1564
|
+
title: `Run SQL on Supabase project ${ref}`,
|
|
1565
|
+
detail: query.slice(0, 3000),
|
|
1566
|
+
danger: /\b(drop|truncate|delete)\b/i.test(query),
|
|
1567
|
+
});
|
|
1568
|
+
if (verdict === 'reject') return { ok: false, output: 'User declined running that SQL.', meta: { rejected: true } };
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
let r;
|
|
1572
|
+
try { r = await oauth.supabaseQuery(token, ref, query); }
|
|
1573
|
+
catch (e) { return { ok: false, output: `Supabase SQL failed: ${e.message}` }; }
|
|
1574
|
+
const firstLine = query.split('\n')[0].slice(0, 80);
|
|
1575
|
+
return { ok: r.ok, output: formatApiResult('Supabase SQL', 'POST', `/v1/projects/${ref}/database/query`, r), meta: { label: firstLine, status: r.status } };
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
async function vercel_api(args, ctx) {
|
|
1579
|
+
const method = normalizeMethod(args.method);
|
|
1580
|
+
if (!method) return { ok: false, output: 'vercel_api <method> must be GET, POST, PUT, PATCH or DELETE.' };
|
|
1581
|
+
const apiPath = String(args.path || '').trim();
|
|
1582
|
+
if (!/^\/v\d+\//.test(apiPath)) return { ok: false, output: 'vercel_api <path> must start with a version, e.g. /v9/projects or /v6/deployments.' };
|
|
1583
|
+
const body = parseJsonBody(args.body);
|
|
1584
|
+
if (!body.ok) return { ok: false, output: body.error };
|
|
1585
|
+
if (method !== 'GET' && READ_ONLY_MODES.has(ctx.mode)) return { ok: false, output: `Blocked - ${ctx.mode} mode is read-only; only GET requests are allowed.` };
|
|
1586
|
+
|
|
1587
|
+
const vercel = config.getIntegration('vercel');
|
|
1588
|
+
if (!vercel.accessToken) return { ok: false, output: VERCEL_NOT_CONNECTED };
|
|
1589
|
+
|
|
1590
|
+
if (method !== 'GET') {
|
|
1591
|
+
const verdict = await ctx.approve({
|
|
1592
|
+
tool: 'vercel_api',
|
|
1593
|
+
title: `Vercel ${method} ${apiPath}`,
|
|
1594
|
+
detail: body.value !== undefined ? JSON.stringify(body.value, null, 2).slice(0, 2000) : '(no body)',
|
|
1595
|
+
danger: method === 'DELETE',
|
|
1596
|
+
});
|
|
1597
|
+
if (verdict === 'reject') return { ok: false, output: `User declined the Vercel ${method} ${apiPath} request.`, meta: { rejected: true } };
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
let r;
|
|
1601
|
+
try { r = await oauth.vercelApi(vercel.accessToken, vercel.teamId, method, apiPath, body.value); }
|
|
1602
|
+
catch (e) { return { ok: false, output: `Vercel request failed: ${e.message}` }; }
|
|
1603
|
+
return { ok: r.ok, output: formatApiResult('Vercel', method, apiPath, r), meta: { label: `${method} ${apiPath}`, status: r.status } };
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// ─── Task list ──────────────────────────────────────────────────────────────
|
|
1607
|
+
// The model's own checklist for a multi-step task. Each call sends the whole
|
|
1608
|
+
// list, one item per line: [ ] pending [>] in progress [x] done [-] dropped
|
|
1609
|
+
// Idea from Hermes Agent's todo tool (MIT, Nous Research): the full list comes
|
|
1610
|
+
// back every time, at most one item is in progress, and "done" is only for
|
|
1611
|
+
// work a tool result has confirmed.
|
|
1612
|
+
|
|
1613
|
+
const TODO_MARKS = { ' ': 'pending', '>': 'in_progress', x: 'done', X: 'done', '-': 'dropped', '~': 'dropped' };
|
|
1614
|
+
const TODO_LABEL = { pending: '[ ]', in_progress: '[>]', done: '[x]', dropped: '[-]' };
|
|
1615
|
+
const MAX_TODOS = 30;
|
|
1616
|
+
|
|
1617
|
+
export function parseTodos(text) {
|
|
1618
|
+
const items = [];
|
|
1619
|
+
for (const raw of String(text || '').split('\n')) {
|
|
1620
|
+
const line = raw.trim().replace(/^[-*]\s+(?=\[)/, '');
|
|
1621
|
+
if (!line) continue;
|
|
1622
|
+
const m = line.match(/^\[(.)\]\s*(.+)$/);
|
|
1623
|
+
items.push(m && TODO_MARKS[m[1]] ? { status: TODO_MARKS[m[1]], text: m[2].trim() } : { status: 'pending', text: line });
|
|
1624
|
+
}
|
|
1625
|
+
return items.slice(0, MAX_TODOS);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
export function formatTodos(items, { openOnly = false } = {}) {
|
|
1629
|
+
return items
|
|
1630
|
+
.filter((t) => !openOnly || t.status === 'pending' || t.status === 'in_progress')
|
|
1631
|
+
.map((t) => `${TODO_LABEL[t.status]} ${t.text}`)
|
|
1632
|
+
.join('\n');
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
async function todo(args, ctx) {
|
|
1636
|
+
const items = parseTodos(args.items);
|
|
1637
|
+
if (!items.length) return { ok: false, output: 'todo needs <items>: one task per line, like "[ ] write the header" or "[x] read index.html".' };
|
|
1638
|
+
// One thing in progress at a time: keep the first, demote the rest.
|
|
1639
|
+
let seenActive = false;
|
|
1640
|
+
for (const t of items) {
|
|
1641
|
+
if (t.status !== 'in_progress') continue;
|
|
1642
|
+
if (seenActive) t.status = 'pending';
|
|
1643
|
+
seenActive = true;
|
|
1644
|
+
}
|
|
1645
|
+
ctx.todos = items;
|
|
1646
|
+
const count = (s) => items.filter((t) => t.status === s).length;
|
|
1647
|
+
const open = count('pending') + count('in_progress');
|
|
1648
|
+
return {
|
|
1649
|
+
ok: true,
|
|
1650
|
+
output: `Task list (${count('done')} done, ${open} open${count('dropped') ? `, ${count('dropped')} dropped` : ''}):\n${formatTodos(items)}` +
|
|
1651
|
+
(open ? '' : '\nEvery item is closed. Finish with your summary unless something is still unverified.'),
|
|
1652
|
+
meta: { label: `${count('done')}/${items.length - count('dropped')} done`, todos: items },
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// ─── Question to the user ───────────────────────────────────────────────────
|
|
1657
|
+
// A real decision only the user can make, asked as a short multiple-choice
|
|
1658
|
+
// question they can answer with one tap (on the PC or the phone). Modeled on
|
|
1659
|
+
// opencode's question tool: short options, the recommended one first, and the
|
|
1660
|
+
// user can always type their own answer instead.
|
|
1661
|
+
|
|
1662
|
+
const MAX_OPTIONS = 5;
|
|
1663
|
+
|
|
1664
|
+
async function ask_user(args, ctx) {
|
|
1665
|
+
const question = String(args.question || '').trim();
|
|
1666
|
+
if (!question) return { ok: false, output: 'ask_user needs a <question>.' };
|
|
1667
|
+
const options = String(args.options || '')
|
|
1668
|
+
.split('\n')
|
|
1669
|
+
.map((l) => l.trim().replace(/^[-*\d.)\s]+/, '').trim())
|
|
1670
|
+
.filter(Boolean)
|
|
1671
|
+
.slice(0, MAX_OPTIONS);
|
|
1672
|
+
if (typeof ctx.ask !== 'function') {
|
|
1673
|
+
return {
|
|
1674
|
+
ok: false,
|
|
1675
|
+
output: 'Asking the user is not available here (unattended run). Pick the most sensible option yourself, say which one and why in your summary, and continue.',
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
const answer = await ctx.ask({ question, options });
|
|
1679
|
+
if (answer == null || answer === '') {
|
|
1680
|
+
return { ok: false, output: 'The user dismissed the question without answering. Make a sensible choice yourself, say which in your summary, and continue.', meta: { label: question.slice(0, 80) } };
|
|
1681
|
+
}
|
|
1682
|
+
return {
|
|
1683
|
+
ok: true,
|
|
1684
|
+
output: `The user answered: ${answer}\nFollow that answer.`,
|
|
1685
|
+
meta: { label: question.slice(0, 80), answer: String(answer).slice(0, 200) },
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// ─── MCP ────────────────────────────────────────────────────────────────────
|
|
1690
|
+
// Tools from the user's configured MCP servers (lib/mcp.js). Anything that
|
|
1691
|
+
// isn't marked read-only by the server itself asks for approval first.
|
|
1692
|
+
|
|
1693
|
+
async function mcp(args, ctx) {
|
|
1694
|
+
const server = String(args.server || '').trim();
|
|
1695
|
+
const tool = String(args.tool || '').trim();
|
|
1696
|
+
if (!server || !tool) return { ok: false, output: 'mcp needs a <server> and a <tool>.' };
|
|
1697
|
+
let toolArgs = {};
|
|
1698
|
+
if (args.args && String(args.args).trim()) {
|
|
1699
|
+
try { toolArgs = JSON.parse(args.args); }
|
|
1700
|
+
catch { return { ok: false, output: '<args> must be a JSON object, e.g. {"query": "x"}.' }; }
|
|
1701
|
+
}
|
|
1702
|
+
const def = mcpLib.findTool(ctx.cwd, server, tool);
|
|
1703
|
+
if (!def) return { ok: false, output: `No tool "${tool}" on MCP server "${server}". Use one listed under MCP SERVERS.` };
|
|
1704
|
+
const label = `${server}/${tool}`;
|
|
1705
|
+
if (!def.annotations?.readOnlyHint) {
|
|
1706
|
+
const verdict = await ctx.approve({
|
|
1707
|
+
tool: 'mcp',
|
|
1708
|
+
title: `Use ${tool} (${server})`,
|
|
1709
|
+
detail: JSON.stringify(toolArgs).slice(0, 600),
|
|
1710
|
+
danger: !!def.annotations?.destructiveHint,
|
|
1711
|
+
});
|
|
1712
|
+
if (verdict === 'reject') return { ok: false, output: `User declined ${label}.`, meta: { rejected: true, label } };
|
|
1713
|
+
}
|
|
1714
|
+
let r;
|
|
1715
|
+
try { r = await mcpLib.callTool(ctx.cwd, server, tool, toolArgs); }
|
|
1716
|
+
catch (e) { return { ok: false, output: `${label} failed: ${e.message}`, meta: { label } }; }
|
|
1717
|
+
return {
|
|
1718
|
+
ok: !r.isError,
|
|
1719
|
+
output: truncate(`${r.isError ? '[the tool reported an error]\n' : ''}${r.text}`),
|
|
1720
|
+
meta: { label, ...(r.images.length ? { imageDataUrls: r.images.slice(0, 4) } : {}) },
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// ─── Web ────────────────────────────────────────────────────────────────────
|
|
1725
|
+
|
|
1726
|
+
async function web_fetch(args, ctx) {
|
|
1727
|
+
const url = String(args.url || '').trim();
|
|
1728
|
+
if (!url) return { ok: false, output: 'web_fetch needs a <url>.' };
|
|
1729
|
+
const format = /^(html|raw)$/i.test(String(args.format || '').trim()) ? String(args.format).trim().toLowerCase() : 'text';
|
|
1730
|
+
const r = await webTools.fetchPage(url, { format, signal: ctx.signal });
|
|
1731
|
+
if (!r.ok) return { ok: false, output: r.error, meta: { label: url } };
|
|
1732
|
+
const note = r.finalUrl && r.finalUrl !== url ? `(redirected to ${r.finalUrl})\n\n` : '';
|
|
1733
|
+
return { ok: true, output: truncate(note + (r.text || '(the page has no text content)')), meta: { label: url } };
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
async function web_search(args, ctx) {
|
|
1737
|
+
const query = String(args.query || '').trim();
|
|
1738
|
+
if (!query) return { ok: false, output: 'web_search needs a <query>.' };
|
|
1739
|
+
const r = await webTools.webSearch(query, { num: Number(args.num) || 8, signal: ctx.signal });
|
|
1740
|
+
if (!r.ok) return { ok: false, output: r.error, meta: { label: query } };
|
|
1741
|
+
return { ok: true, output: truncate(r.text), meta: { label: query } };
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// ─── lsp ────────────────────────────────────────────────────────────────────
|
|
1745
|
+
|
|
1746
|
+
const LSP_OPS = new Set(['definition', 'references', 'implementation', 'hover', 'documentsymbol', 'workspacesymbol']);
|
|
1747
|
+
const LSP_CANON = { documentsymbol: 'documentSymbol', workspacesymbol: 'workspaceSymbol' };
|
|
1748
|
+
|
|
1749
|
+
async function lsp(args, ctx) {
|
|
1750
|
+
const key = String(args.operation || '').trim().toLowerCase().replace(/[\s_-]/g, '');
|
|
1751
|
+
if (!LSP_OPS.has(key)) return { ok: false, output: 'lsp needs an <operation>: definition, references, implementation, hover, documentSymbol or workspaceSymbol.' };
|
|
1752
|
+
const op = LSP_CANON[key] || key;
|
|
1753
|
+
const req = { op };
|
|
1754
|
+
if (op === 'workspaceSymbol') {
|
|
1755
|
+
if (!String(args.query || args.symbol || '').trim()) return { ok: false, output: 'workspaceSymbol needs a <query>.' };
|
|
1756
|
+
req.query = String(args.query || args.symbol).trim();
|
|
1757
|
+
} else {
|
|
1758
|
+
if (!args.path) return { ok: false, output: `lsp ${op} needs a <path>.` };
|
|
1759
|
+
const { abs, rel, outside } = resolvePath(args.path, ctx.cwd);
|
|
1760
|
+
if (outside) return { ok: false, output: `${rel} is outside the project.` };
|
|
1761
|
+
if (!fs.existsSync(abs)) return { ok: false, output: `No such file: ${rel}.` };
|
|
1762
|
+
req.file = abs;
|
|
1763
|
+
if (op !== 'documentSymbol') {
|
|
1764
|
+
req.line = Number(args.line) || 1;
|
|
1765
|
+
if (args.character) req.character = Number(args.character);
|
|
1766
|
+
if (args.symbol) req.symbol = String(args.symbol).trim();
|
|
1767
|
+
if (!req.character && !req.symbol) return { ok: false, output: `lsp ${op} needs a <symbol> (or a <character> column) to know what to look up.` };
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
const r = await diagnostics.navigate(req, ctx.cwd);
|
|
1771
|
+
const label = op === 'workspaceSymbol' ? `${op} ${req.query}` : `${op} ${resolvePath(args.path, ctx.cwd).rel}${args.symbol ? ` ${args.symbol}` : ''}`;
|
|
1772
|
+
return { ok: r.ok, output: truncate(r.text), meta: { label } };
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// ─── apply_patch ────────────────────────────────────────────────────────────
|
|
1776
|
+
// Several file changes in one block (add / update / delete / move). Planned
|
|
1777
|
+
// purely first, shown to the user as one approval, then written.
|
|
1778
|
+
|
|
1779
|
+
async function apply_patch(args, ctx) {
|
|
1780
|
+
const patch = args.patch;
|
|
1781
|
+
if (!patch || !String(patch).trim()) return { ok: false, output: 'apply_patch needs a <patch> block starting with "*** Begin Patch".' };
|
|
1782
|
+
|
|
1783
|
+
const readFile = (p) => {
|
|
1784
|
+
const { abs } = resolvePath(p, ctx.cwd);
|
|
1785
|
+
try { return fs.statSync(abs).isFile() ? fs.readFileSync(abs, 'utf8') : null; } catch { return null; }
|
|
1786
|
+
};
|
|
1787
|
+
const plan = applyPatchLib.planPatch(patch, readFile);
|
|
1788
|
+
if (plan.error) return { ok: false, output: `Patch not applied: ${plan.error}` };
|
|
1789
|
+
|
|
1790
|
+
for (const c of plan.changes) {
|
|
1791
|
+
if (c.kind === 'add' || c.kind === 'delete') continue;
|
|
1792
|
+
const { abs } = resolvePath(c.path, ctx.cwd);
|
|
1793
|
+
if (changedSinceSeen(ctx, abs)) {
|
|
1794
|
+
ctx.fileState.delete(abs);
|
|
1795
|
+
return { ok: false, output: `Patch not applied: ${c.path} changed on disk after you last read it. Read it again and rebuild the patch from its current content.` };
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
const resolved = plan.changes.map((c) => ({
|
|
1800
|
+
...c,
|
|
1801
|
+
abs: resolvePath(c.path, ctx.cwd),
|
|
1802
|
+
absNew: c.newPath ? resolvePath(c.newPath, ctx.cwd) : null,
|
|
1803
|
+
}));
|
|
1804
|
+
const label = (c) => (c.kind === 'move' ? `${c.path} -> ${c.newPath}` : c.path);
|
|
1805
|
+
const verb = { add: 'add', update: 'update', delete: 'delete', move: 'move' };
|
|
1806
|
+
const detail = resolved.map((c) => `${verb[c.kind]} ${label(c)} (+${c.added} -${c.removed})`).join('\n');
|
|
1807
|
+
const totalAdded = plan.changes.reduce((n, c) => n + c.added, 0);
|
|
1808
|
+
const totalRemoved = plan.changes.reduce((n, c) => n + c.removed, 0);
|
|
1809
|
+
const danger = resolved.some((c) => c.abs.outside || c.absNew?.outside) || plan.changes.some((c) => c.kind === 'delete');
|
|
1810
|
+
// The approval card shows removed and added lines side by side, as it does for edit_file.
|
|
1811
|
+
const patchLines = (sign) => String(patch).split('\n')
|
|
1812
|
+
.filter((l) => l.startsWith(sign) && !l.startsWith(sign.repeat(3) + ' '))
|
|
1813
|
+
.map((l) => l.slice(1)).join('\n').slice(0, 4000);
|
|
1814
|
+
|
|
1815
|
+
const verdict = await ctx.approve({
|
|
1816
|
+
tool: 'apply_patch',
|
|
1817
|
+
title: plan.changes.length === 1 ? `Patch ${label(plan.changes[0])}` : `Apply patch (${plan.changes.length} files)`,
|
|
1818
|
+
detail,
|
|
1819
|
+
diff: {
|
|
1820
|
+
search: patchLines('-'),
|
|
1821
|
+
replace: patchLines('+'),
|
|
1822
|
+
},
|
|
1823
|
+
danger,
|
|
1824
|
+
});
|
|
1825
|
+
if (verdict === 'reject') return { ok: false, output: 'User declined the patch. Nothing was changed.', meta: { rejected: true } };
|
|
1826
|
+
|
|
1827
|
+
const done = [];
|
|
1828
|
+
const files = [];
|
|
1829
|
+
const changes = [];
|
|
1830
|
+
const problemsByFile = {};
|
|
1831
|
+
try {
|
|
1832
|
+
for (const c of resolved) {
|
|
1833
|
+
if (c.kind === 'delete') {
|
|
1834
|
+
fs.unlinkSync(c.abs.abs);
|
|
1835
|
+
ctx.fileState?.delete(c.abs.abs);
|
|
1836
|
+
done.push(`deleted ${c.path}`);
|
|
1837
|
+
files.push(c.path);
|
|
1838
|
+
changes.push({ path: c.path, kind: 'delete', added: 0, removed: c.removed });
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
const dest = c.kind === 'move' ? c.absNew : c.abs;
|
|
1842
|
+
fs.mkdirSync(path.dirname(dest.abs), { recursive: true });
|
|
1843
|
+
fs.writeFileSync(dest.abs, c.after, 'utf8');
|
|
1844
|
+
if (c.kind === 'move' && c.abs.abs !== dest.abs) {
|
|
1845
|
+
fs.unlinkSync(c.abs.abs);
|
|
1846
|
+
ctx.fileState?.delete(c.abs.abs);
|
|
1847
|
+
}
|
|
1848
|
+
rememberDiskState(ctx, dest.abs);
|
|
1849
|
+
const shown = c.kind === 'move' ? c.newPath : c.path;
|
|
1850
|
+
done.push(c.kind === 'add' ? `added ${shown}` : c.kind === 'move' ? `moved ${c.path} to ${c.newPath}` : `updated ${shown}`);
|
|
1851
|
+
files.push(shown);
|
|
1852
|
+
changes.push({ path: shown, kind: c.kind, added: c.added, removed: c.removed });
|
|
1853
|
+
const problems = await diagnostics.checkFile(dest.abs, ctx.cwd);
|
|
1854
|
+
if (problems.length) problemsByFile[shown] = problems;
|
|
1855
|
+
}
|
|
1856
|
+
} catch (e) {
|
|
1857
|
+
return {
|
|
1858
|
+
ok: false,
|
|
1859
|
+
output: `Patch failed part-way: ${e.message}. Done so far: ${done.join('; ') || 'nothing'}. Check the project state with list_dir/read_file before retrying.`,
|
|
1860
|
+
meta: { files, changes, wrote: files.length > 0 },
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
const problemText = Object.entries(problemsByFile).map(([f, p]) => problemsNote(f, p)).join('');
|
|
1865
|
+
return {
|
|
1866
|
+
ok: true,
|
|
1867
|
+
output: `Patch applied: ${done.join('; ')}. The changes succeeded exactly as sent - do NOT read the files back to confirm.` + problemText,
|
|
1868
|
+
meta: { label: files.length === 1 ? files[0] : `${files.length} files`, added: totalAdded, removed: totalRemoved, wrote: true, files, changes, problems: Object.values(problemsByFile).flat(), problemsByFile },
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// ─── Plan mode ──────────────────────────────────────────────────────────────
|
|
1873
|
+
// Plan mode writes its plan to .codeply/plans/<slug>.md, then plan_exit asks
|
|
1874
|
+
// the user to switch to Build. plan_enter is the reverse, and always asks.
|
|
1875
|
+
|
|
1876
|
+
function latestPlanFile(cwd) {
|
|
1877
|
+
const dir = path.join(cwd, PLAN_DIR);
|
|
1878
|
+
let best = null;
|
|
1879
|
+
try {
|
|
1880
|
+
for (const f of fs.readdirSync(dir)) {
|
|
1881
|
+
if (!/\.md$/i.test(f)) continue;
|
|
1882
|
+
const m = fs.statSync(path.join(dir, f)).mtimeMs;
|
|
1883
|
+
if (!best || m > best.m) best = { f: `${PLAN_DIR}/${f}`, m };
|
|
1884
|
+
}
|
|
1885
|
+
} catch {}
|
|
1886
|
+
return best ? best.f : null;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
async function plan_exit(args, ctx) {
|
|
1890
|
+
if (ctx.mode !== 'Plan') return { ok: false, output: 'plan_exit only works in Plan mode.' };
|
|
1891
|
+
let rel = args.path ? resolvePath(String(args.path).trim(), ctx.cwd).rel : latestPlanFile(ctx.cwd);
|
|
1892
|
+
if (!rel || !isPlanPath(rel, ctx.cwd) || !fs.existsSync(resolvePath(rel, ctx.cwd).abs)) {
|
|
1893
|
+
return { ok: false, output: `Write your plan to ${PLAN_DIR}/<short-name>.md with write_file first, then call plan_exit.` };
|
|
1894
|
+
}
|
|
1895
|
+
if (typeof ctx.ask !== 'function') {
|
|
1896
|
+
return { ok: false, output: `Switching modes needs the user's answer, and asking is not available here. The plan is saved at ${rel}; stay in Plan mode and summarise it.`, meta: { label: rel } };
|
|
1897
|
+
}
|
|
1898
|
+
const answer = await ctx.ask({
|
|
1899
|
+
question: `The plan is ready (${rel}). Switch to Build mode and start implementing it?`,
|
|
1900
|
+
options: ['Yes, start building (Recommended)', 'No, keep planning'],
|
|
1901
|
+
});
|
|
1902
|
+
if (answer != null && /^\s*yes/i.test(String(answer))) {
|
|
1903
|
+
return {
|
|
1904
|
+
ok: true,
|
|
1905
|
+
output: `The user approved the plan. You are now in Build mode. Implement the plan in ${rel}: read it, then work through it, ticking off each step.`,
|
|
1906
|
+
meta: { label: rel, switchMode: 'Build', planPath: rel },
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
const feedback = answer && !/^\s*no\b/i.test(String(answer)) ? ` The user said: ${answer}` : '';
|
|
1910
|
+
return { ok: true, output: `The user wants to keep planning.${feedback} Revise the plan file and call plan_exit again when it is ready.`, meta: { label: rel } };
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
async function plan_enter(args, ctx) {
|
|
1914
|
+
if (ctx.mode !== 'Build') return { ok: false, output: 'plan_enter only works in Build mode.' };
|
|
1915
|
+
if (typeof ctx.ask !== 'function') return { ok: false, output: 'Switching modes needs the user\'s answer, and asking is not available here. Carry on in Build mode.' };
|
|
1916
|
+
const reason = String(args.reason || '').trim();
|
|
1917
|
+
const answer = await ctx.ask({
|
|
1918
|
+
question: `Switch to Plan mode${reason ? ` (${reason})` : ''}? Nothing will be changed until you approve a plan.`,
|
|
1919
|
+
options: ['Yes, plan first', 'No, keep building (Recommended)'],
|
|
1920
|
+
});
|
|
1921
|
+
if (answer != null && /^\s*yes/i.test(String(answer))) {
|
|
1922
|
+
return { ok: true, output: `You are now in Plan mode (read-only). Explore, write the plan to ${PLAN_DIR}/<short-name>.md, then call plan_exit.`, meta: { label: 'plan', switchMode: 'Plan' } };
|
|
1923
|
+
}
|
|
1924
|
+
return { ok: true, output: 'The user wants to keep building. Carry on in Build mode.', meta: { label: 'plan' } };
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
export const TOOLS = {
|
|
1928
|
+
todo,
|
|
1929
|
+
ask_user,
|
|
1930
|
+
mcp,
|
|
1931
|
+
web_fetch, web_search, apply_patch, plan_exit, plan_enter, lsp,
|
|
1932
|
+
list_dir, read_file, write_file, edit_file, search, run, use_skill, list_skills, fetch_image, browser_check,
|
|
1933
|
+
gmail_send, gmail_search, slack_post_message, vercel_deploy, supabase_create_project, supabase_delete_project, github_create_repo,
|
|
1934
|
+
design_reference_search, view_images, supabase_api, supabase_sql, vercel_api,
|
|
1935
|
+
};
|
|
332
1936
|
|
|
333
|
-
|
|
1937
|
+
// Tools that can change something. supabase_api/supabase_sql/vercel_api only
|
|
1938
|
+
// actually prompt for non-read requests (see above); read-only tools
|
|
1939
|
+
// (browser_check, gmail_search, design_reference_search, view_images, ...)
|
|
1940
|
+
// never prompt.
|
|
1941
|
+
export const TOOL_NEEDS_APPROVAL = new Set(['write_file', 'edit_file', 'apply_patch', 'run', 'fetch_image', 'gmail_send', 'slack_post_message', 'vercel_deploy', 'supabase_create_project', 'supabase_delete_project', 'github_create_repo', 'supabase_api', 'supabase_sql', 'vercel_api']);
|
|
334
1942
|
|
|
335
1943
|
/** Human-facing verb + colour hint for the transcript. */
|
|
336
1944
|
export const TOOL_DISPLAY = {
|
|
337
|
-
|
|
1945
|
+
todo: { verb: 'plan', icon: '☐' },
|
|
1946
|
+
ask_user: { verb: 'ask', icon: '?' },
|
|
1947
|
+
mcp: { verb: 'mcp', icon: '⧉' },
|
|
1948
|
+
web_fetch: { verb: 'fetch', icon: '⇩' },
|
|
1949
|
+
web_search: { verb: 'web', icon: '▸' },
|
|
1950
|
+
apply_patch:{ verb: 'patch', icon: '✎' },
|
|
1951
|
+
plan_exit: { verb: 'plan', icon: '☐' },
|
|
1952
|
+
plan_enter: { verb: 'plan', icon: '☐' },
|
|
1953
|
+
lsp: { verb: 'lsp', icon: '▸' },
|
|
1954
|
+
list_dir: { verb: 'list', icon: '▸' },
|
|
338
1955
|
read_file: { verb: 'read', icon: '▸' },
|
|
339
1956
|
write_file: { verb: 'write', icon: '✎' },
|
|
340
1957
|
edit_file: { verb: 'edit', icon: '✎' },
|
|
341
1958
|
search: { verb: 'search', icon: '▸' },
|
|
342
1959
|
run: { verb: 'run', icon: '$' },
|
|
1960
|
+
use_skill: { verb: 'skill', icon: '★' },
|
|
1961
|
+
list_skills:{ verb: 'skills', icon: '★' },
|
|
1962
|
+
fetch_image:{ verb: 'fetch', icon: '⇩' },
|
|
1963
|
+
browser_check:{ verb: 'check', icon: '◎' },
|
|
1964
|
+
gmail_send: { verb: 'email', icon: '✉' },
|
|
1965
|
+
gmail_search:{ verb: 'search', icon: '✉' },
|
|
1966
|
+
slack_post_message:{ verb: 'post', icon: '#' },
|
|
1967
|
+
vercel_deploy:{ verb: 'deploy', icon: '▲' },
|
|
1968
|
+
supabase_create_project:{ verb: 'provision', icon: '◆' },
|
|
1969
|
+
supabase_delete_project:{ verb: 'delete', icon: '◆' },
|
|
1970
|
+
github_create_repo:{ verb: 'push', icon: '⎇' },
|
|
1971
|
+
design_reference_search:{ verb: 'reference', icon: '◫' },
|
|
1972
|
+
view_images:{ verb: 'view', icon: '◉' },
|
|
1973
|
+
supabase_api:{ verb: 'supabase', icon: '◆' },
|
|
1974
|
+
supabase_sql:{ verb: 'sql', icon: '◆' },
|
|
1975
|
+
vercel_api:{ verb: 'vercel', icon: '▲' },
|
|
343
1976
|
};
|
|
344
1977
|
|
|
1978
|
+
// Plan and Ask are read-only. write_file/edit_file are also refused in
|
|
1979
|
+
// agent.mjs; this covers every other tool that changes something outside the
|
|
1980
|
+
// conversation, so read-only can't be escaped through a side door.
|
|
1981
|
+
const MUTATING_TOOLS = new Set([
|
|
1982
|
+
'write_file', 'edit_file', 'apply_patch', 'fetch_image', 'gmail_send', 'slack_post_message',
|
|
1983
|
+
'vercel_deploy', 'supabase_create_project', 'supabase_delete_project', 'github_create_repo',
|
|
1984
|
+
]);
|
|
1985
|
+
|
|
345
1986
|
export async function executeTool(name, args, ctx) {
|
|
346
1987
|
const fn = TOOLS[name];
|
|
347
1988
|
if (!fn) {
|
|
348
1989
|
return { ok: false, output: `Unknown tool "${name}". Available: ${Object.keys(TOOLS).join(', ')}.` };
|
|
349
1990
|
}
|
|
350
1991
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const capped = CAPPED_TOOLS.has(name) && config.getConfig().provider === 'codeply';
|
|
355
|
-
if (capped) {
|
|
356
|
-
const limit = await applyLimit.checkApplyLimit();
|
|
357
|
-
if (!limit.allowed) {
|
|
358
|
-
return {
|
|
359
|
-
ok: false,
|
|
360
|
-
output: limit.error || `Daily apply limit reached (${limit.count}/${limit.limit}). This is shared with the Codeply desktop app and resets at midnight UTC.`,
|
|
361
|
-
};
|
|
362
|
-
}
|
|
1992
|
+
const planFileWrite = ctx.mode === 'Plan' && (name === 'write_file' || name === 'edit_file') && isPlanPath(args.path, ctx.cwd);
|
|
1993
|
+
if (READ_ONLY_MODES.has(ctx.mode) && MUTATING_TOOLS.has(name) && !planFileWrite) {
|
|
1994
|
+
return { ok: false, output: `Blocked - ${ctx.mode} mode is read-only, so ${name} is not available. Describe what you would do instead, or tell the user to switch to Build mode.` };
|
|
363
1995
|
}
|
|
364
1996
|
|
|
1997
|
+
const usingHosted = ctx.route ? !!ctx.route.auto : config.getConfig().provider === 'codeply';
|
|
1998
|
+
|
|
365
1999
|
try {
|
|
366
2000
|
const result = await fn(args, ctx);
|
|
367
|
-
if (
|
|
368
|
-
|
|
2001
|
+
if (usingHosted && RECORDED_TOOLS.has(name) && result.ok && result.meta?.wrote) {
|
|
2002
|
+
applyLimit.recordApplyEvent(args.path, result.meta.added, result.meta.removed); // fire-and-forget
|
|
2003
|
+
} else if (usingHosted && name === 'apply_patch' && result.meta?.changes) {
|
|
2004
|
+
for (const c of result.meta.changes) applyLimit.recordApplyEvent(c.path, c.added, c.removed);
|
|
369
2005
|
}
|
|
370
2006
|
return result;
|
|
371
2007
|
} catch (e) {
|
|
@@ -373,4 +2009,4 @@ export async function executeTool(name, args, ctx) {
|
|
|
373
2009
|
}
|
|
374
2010
|
}
|
|
375
2011
|
|
|
376
|
-
export { resolvePath, walkFiles, truncate };
|
|
2012
|
+
export { resolvePath, walkFiles, truncate, isPlanPath };
|