codeply-cli 0.3.1 → 0.4.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 -63
- package/bin/codeply.js +127 -0
- package/lib/agent.mjs +2139 -2139
- package/lib/ai.mjs +6 -6
- package/lib/apply-patch.js +217 -217
- package/lib/arity-table.json +138 -138
- package/lib/arity.js +70 -70
- package/lib/auth.mjs +6 -6
- package/lib/cloud.mjs +708 -0
- package/lib/commands.js +103 -103
- package/lib/design-library/assemble-artifact.mjs +21 -21
- package/lib/design-library/cleanup.mjs +45 -45
- package/lib/design-library/index.json +22379 -22379
- package/lib/design-library/make-table-data.mjs +39 -39
- package/lib/diagnostics.js +192 -192
- package/lib/edit-engine.js +364 -364
- package/lib/github-agent.mjs +311 -307
- package/lib/history.js +75 -75
- package/lib/mcp.js +258 -258
- package/lib/native-tools.mjs +189 -189
- package/lib/oauth-connectors.js +2 -1
- package/lib/permissions.js +109 -109
- package/lib/plugins.js +353 -353
- package/lib/server.mjs +457 -457
- package/lib/session-db.js +220 -220
- package/lib/share.js +168 -168
- package/lib/skills.js +416 -416
- package/lib/snapshot.js +146 -146
- package/lib/terminal-hints.js +154 -154
- package/lib/tools.mjs +2012 -2012
- package/lib/typecheck-worker.js +147 -147
- package/lib/web-tools.js +158 -158
- package/package.json +49 -47
- package/skills/SOURCE.md +58 -58
- package/skills/accessibility/SKILL.md +147 -147
- package/skills/agent-architecture-audit/SKILL.md +257 -257
- package/skills/agent-eval/SKILL.md +147 -147
- package/skills/agent-harness-construction/SKILL.md +74 -74
- package/skills/agent-introspection-debugging/SKILL.md +154 -154
- package/skills/agent-payment-x402/SKILL.md +225 -225
- package/skills/agent-self-evaluation/SKILL.md +182 -182
- package/skills/agent-self-evaluation/references/evaluation-criteria.md +71 -71
- package/skills/agent-self-evaluation/references/hook-integration.md +64 -64
- package/skills/agent-sort/SKILL.md +216 -216
- package/skills/agentic-engineering/SKILL.md +64 -64
- package/skills/agentic-os/SKILL.md +388 -388
- package/skills/ai-first-engineering/SKILL.md +52 -52
- package/skills/ai-regression-testing/SKILL.md +386 -386
- package/skills/android-clean-architecture/SKILL.md +340 -340
- package/skills/angular-developer/SKILL.md +155 -155
- package/skills/angular-developer/references/angular-animations.md +160 -160
- package/skills/angular-developer/references/angular-aria.md +410 -410
- package/skills/angular-developer/references/cli.md +86 -86
- package/skills/angular-developer/references/component-harnesses.md +59 -59
- package/skills/angular-developer/references/component-styling.md +91 -91
- package/skills/angular-developer/references/components.md +117 -117
- package/skills/angular-developer/references/creating-services.md +97 -97
- package/skills/angular-developer/references/data-resolvers.md +69 -69
- package/skills/angular-developer/references/define-routes.md +67 -67
- package/skills/angular-developer/references/defining-providers.md +72 -72
- package/skills/angular-developer/references/di-fundamentals.md +120 -120
- package/skills/angular-developer/references/e2e-testing.md +56 -56
- package/skills/angular-developer/references/effects.md +83 -83
- package/skills/angular-developer/references/hierarchical-injectors.md +43 -43
- package/skills/angular-developer/references/host-elements.md +80 -80
- package/skills/angular-developer/references/injection-context.md +63 -63
- package/skills/angular-developer/references/inputs.md +101 -101
- package/skills/angular-developer/references/linked-signal.md +59 -59
- package/skills/angular-developer/references/loading-strategies.md +61 -61
- package/skills/angular-developer/references/mcp.md +108 -108
- package/skills/angular-developer/references/navigate-to-routes.md +69 -69
- package/skills/angular-developer/references/outputs.md +86 -86
- package/skills/angular-developer/references/reactive-forms.md +122 -122
- package/skills/angular-developer/references/rendering-strategies.md +44 -44
- package/skills/angular-developer/references/resource.md +77 -77
- package/skills/angular-developer/references/route-animations.md +56 -56
- package/skills/angular-developer/references/route-guards.md +52 -52
- package/skills/angular-developer/references/router-lifecycle.md +45 -45
- package/skills/angular-developer/references/router-testing.md +87 -87
- package/skills/angular-developer/references/show-routes-with-outlets.md +68 -68
- package/skills/angular-developer/references/signal-forms.md +795 -795
- package/skills/angular-developer/references/signals-overview.md +94 -94
- package/skills/angular-developer/references/tailwind-css.md +69 -69
- package/skills/angular-developer/references/template-driven-forms.md +114 -114
- package/skills/angular-developer/references/testing-fundamentals.md +65 -65
- package/skills/api-connector-builder/SKILL.md +121 -121
- package/skills/api-design/SKILL.md +524 -524
- package/skills/architecture-decision-records/SKILL.md +180 -180
- package/skills/article-writing/SKILL.md +80 -80
- package/skills/automation-audit-ops/SKILL.md +143 -143
- package/skills/autonomous-agent-harness/SKILL.md +274 -274
- package/skills/autonomous-loops/SKILL.md +611 -611
- package/skills/backend-patterns/SKILL.md +562 -562
- package/skills/benchmark/SKILL.md +95 -95
- package/skills/benchmark-methodology/SKILL.md +191 -191
- package/skills/benchmark-optimization-loop/SKILL.md +71 -71
- package/skills/blender-motion-state-inspection/SKILL.md +165 -165
- package/skills/blueprint/SKILL.md +106 -106
- package/skills/brand-discovery/SKILL.md +145 -145
- package/skills/brand-discovery/references/10_purpose-why.md +40 -40
- package/skills/brand-discovery/references/20_positioning.md +44 -44
- package/skills/brand-discovery/references/30_audience-niche.md +52 -52
- package/skills/brand-discovery/references/40_personality-archetype.md +57 -57
- package/skills/brand-discovery/references/50_voice-tone.md +59 -59
- package/skills/brand-discovery/references/60_narrative-story.md +50 -50
- package/skills/brand-discovery/references/70_founder-tension.md +49 -49
- package/skills/brand-discovery/references/90_SYNTHESIS.md +133 -133
- package/skills/brand-voice/SKILL.md +98 -98
- package/skills/brand-voice/references/voice-profile-schema.md +55 -55
- package/skills/browser-qa/SKILL.md +105 -105
- package/skills/bun-runtime/SKILL.md +85 -85
- package/skills/canary-watch/SKILL.md +108 -108
- package/skills/carrier-relationship-management/SKILL.md +212 -212
- package/skills/cisco-ios-patterns/SKILL.md +164 -164
- package/skills/ck/SKILL.md +148 -148
- package/skills/claude-devfleet/SKILL.md +112 -112
- package/skills/click-path-audit/SKILL.md +245 -245
- package/skills/clickhouse-io/SKILL.md +445 -445
- package/skills/code-tour/SKILL.md +254 -254
- package/skills/codebase-onboarding/SKILL.md +234 -234
- package/skills/codehealth-mcp/SKILL.md +167 -167
- package/skills/coding-standards/SKILL.md +551 -551
- package/skills/competitive-platform-analysis/SKILL.md +214 -214
- package/skills/competitive-report-structure/SKILL.md +162 -162
- package/skills/compose-multiplatform-patterns/SKILL.md +300 -300
- package/skills/config-gc/SKILL.md +120 -120
- package/skills/configure-ecc/SKILL.md +385 -385
- package/skills/connections-optimizer/SKILL.md +190 -190
- package/skills/content-engine/SKILL.md +132 -132
- package/skills/content-hash-cache-pattern/SKILL.md +162 -162
- package/skills/context-budget/SKILL.md +136 -136
- package/skills/continuous-agent-loop/SKILL.md +46 -46
- package/skills/continuous-learning/SKILL.md +132 -132
- package/skills/continuous-learning-v2/SKILL.md +361 -361
- package/skills/contract-first/SKILL.md +287 -287
- package/skills/cost-aware-llm-pipeline/SKILL.md +184 -184
- package/skills/cost-tracking/SKILL.md +97 -97
- package/skills/council/SKILL.md +204 -204
- package/skills/cpp-coding-standards/SKILL.md +724 -724
- package/skills/cpp-testing/SKILL.md +325 -325
- package/skills/crosspost/SKILL.md +112 -112
- package/skills/csharp-testing/SKILL.md +322 -322
- package/skills/customer-billing-ops/SKILL.md +141 -141
- package/skills/customs-trade-compliance/SKILL.md +263 -263
- package/skills/dart-flutter-patterns/SKILL.md +564 -564
- package/skills/dashboard-builder/SKILL.md +109 -109
- package/skills/data-scraper-agent/SKILL.md +765 -765
- package/skills/data-throughput-accelerator/SKILL.md +74 -74
- package/skills/database-migrations/SKILL.md +430 -430
- package/skills/deep-research/SKILL.md +160 -160
- package/skills/defi-amm-security/SKILL.md +167 -167
- package/skills/delivery-gate/SKILL.md +126 -126
- package/skills/deployment-patterns/SKILL.md +428 -428
- package/skills/design-system/SKILL.md +83 -83
- package/skills/django-celery/SKILL.md +458 -458
- package/skills/django-patterns/SKILL.md +735 -735
- package/skills/django-security/SKILL.md +644 -644
- package/skills/django-tdd/SKILL.md +730 -730
- package/skills/django-verification/SKILL.md +470 -470
- package/skills/dmux-workflows/SKILL.md +192 -192
- package/skills/docker-patterns/SKILL.md +365 -365
- package/skills/documentation-lookup/SKILL.md +91 -91
- package/skills/dotnet-patterns/SKILL.md +322 -322
- package/skills/dynamic-workflow-mode/SKILL.md +124 -124
- package/skills/e2e-testing/SKILL.md +327 -327
- package/skills/ecc-guide/SKILL.md +190 -190
- package/skills/ecc-recipes/SKILL.md +149 -149
- package/skills/ecc-tools-cost-audit/SKILL.md +161 -161
- package/skills/email-ops/SKILL.md +122 -122
- package/skills/energy-procurement/SKILL.md +228 -228
- package/skills/enterprise-agent-ops/SKILL.md +51 -51
- package/skills/error-handling/SKILL.md +377 -377
- package/skills/eval-harness/SKILL.md +271 -271
- package/skills/evm-token-decimals/SKILL.md +131 -131
- package/skills/exa-search/SKILL.md +108 -108
- package/skills/fal-ai-media/SKILL.md +289 -289
- package/skills/fastapi-patterns/SKILL.md +514 -514
- package/skills/finance-billing-ops/SKILL.md +128 -128
- package/skills/flox-environments/SKILL.md +497 -497
- package/skills/flutter-dart-code-review/SKILL.md +436 -436
- package/skills/foundation-models-on-device/SKILL.md +243 -243
- package/skills/frontend-a11y/SKILL.md +446 -446
- package/skills/frontend-design-direction/SKILL.md +93 -93
- package/skills/frontend-patterns/SKILL.md +657 -657
- package/skills/frontend-slides/SKILL.md +185 -185
- package/skills/fsharp-testing/SKILL.md +281 -281
- package/skills/gan-style-harness/SKILL.md +279 -279
- package/skills/gateguard/SKILL.md +133 -133
- package/skills/generating-python-installer/SKILL.md +820 -820
- package/skills/git-workflow/SKILL.md +716 -716
- package/skills/github-ops/SKILL.md +145 -145
- package/skills/golang-patterns/SKILL.md +676 -676
- package/skills/golang-testing/SKILL.md +721 -721
- package/skills/google-workspace-ops/SKILL.md +96 -96
- package/skills/growth-log/SKILL.md +128 -128
- package/skills/healthcare-cdss-patterns/SKILL.md +246 -246
- package/skills/healthcare-emr-patterns/SKILL.md +160 -160
- package/skills/healthcare-eval-harness/SKILL.md +208 -208
- package/skills/healthcare-phi-compliance/SKILL.md +146 -146
- package/skills/hermes-imports/SKILL.md +89 -89
- package/skills/hexagonal-architecture/SKILL.md +277 -277
- package/skills/hipaa-compliance/SKILL.md +79 -79
- package/skills/homelab-network-readiness/SKILL.md +170 -170
- package/skills/homelab-network-setup/SKILL.md +130 -130
- package/skills/homelab-pihole-dns/SKILL.md +275 -275
- package/skills/homelab-vlan-segmentation/SKILL.md +312 -312
- package/skills/homelab-wireguard-vpn/SKILL.md +306 -306
- package/skills/hookify-rules/SKILL.md +128 -128
- package/skills/inherit-legacy-style/SKILL.md +157 -157
- package/skills/intent-driven-development/SKILL.md +360 -360
- package/skills/inventory-demand-planning/SKILL.md +247 -247
- package/skills/investor-materials/SKILL.md +97 -97
- package/skills/investor-outreach/SKILL.md +92 -92
- package/skills/ios-icon-gen/SKILL.md +158 -158
- package/skills/iterative-retrieval/SKILL.md +212 -212
- package/skills/ito-basket-compare/SKILL.md +64 -64
- package/skills/ito-compute/SKILL.md +129 -129
- package/skills/ito-data-atlas-agent/SKILL.md +64 -64
- package/skills/ito-market-intelligence/SKILL.md +61 -61
- package/skills/ito-trade-planner/SKILL.md +68 -68
- package/skills/java-coding-standards/SKILL.md +384 -384
- package/skills/jira-integration/SKILL.md +303 -303
- package/skills/jpa-patterns/SKILL.md +152 -152
- package/skills/knowledge-ops/SKILL.md +155 -155
- package/skills/kotlin-coroutines-flows/SKILL.md +285 -285
- package/skills/kotlin-exposed-patterns/SKILL.md +720 -720
- package/skills/kotlin-ktor-patterns/SKILL.md +690 -690
- package/skills/kotlin-patterns/SKILL.md +712 -712
- package/skills/kotlin-testing/SKILL.md +825 -825
- package/skills/kubernetes-patterns/SKILL.md +756 -756
- package/skills/laravel-patterns/SKILL.md +416 -416
- package/skills/laravel-plugin-discovery/SKILL.md +230 -230
- package/skills/laravel-security/SKILL.md +948 -948
- package/skills/laravel-tdd/SKILL.md +675 -675
- package/skills/laravel-verification/SKILL.md +180 -180
- package/skills/latency-critical-systems/SKILL.md +75 -75
- package/skills/lead-intelligence/SKILL.md +322 -322
- package/skills/liquid-glass-design/SKILL.md +279 -279
- package/skills/llm-trading-agent-security/SKILL.md +147 -147
- package/skills/logistics-exception-management/SKILL.md +222 -222
- package/skills/loop-design-check/SKILL.md +143 -143
- package/skills/mailtrap-email-integration/SKILL.md +77 -77
- package/skills/make-interfaces-feel-better/SKILL.md +152 -152
- package/skills/manim-video/SKILL.md +90 -90
- package/skills/market-research/SKILL.md +76 -76
- package/skills/marketing-campaign/SKILL.md +114 -114
- package/skills/mcp-server-patterns/SKILL.md +70 -70
- package/skills/messages-ops/SKILL.md +105 -105
- package/skills/ml-adoption-playbook/SKILL.md +57 -57
- package/skills/mle-workflow/SKILL.md +348 -348
- package/skills/motion-advanced/SKILL.md +596 -596
- package/skills/motion-foundations/SKILL.md +299 -299
- package/skills/motion-patterns/SKILL.md +434 -434
- package/skills/motion-ui/SKILL.md +576 -576
- package/skills/mysql-patterns/SKILL.md +413 -413
- package/skills/nanoclaw-repl/SKILL.md +34 -34
- package/skills/nestjs-patterns/SKILL.md +231 -231
- package/skills/netmiko-ssh-automation/SKILL.md +174 -174
- package/skills/network-bgp-diagnostics/SKILL.md +168 -168
- package/skills/network-config-validation/SKILL.md +211 -211
- package/skills/network-interface-health/SKILL.md +153 -153
- package/skills/nextjs-turbopack/SKILL.md +58 -58
- package/skills/nodejs-keccak256/SKILL.md +103 -103
- package/skills/nutrient-document-processing/SKILL.md +168 -168
- package/skills/nuxt4-patterns/SKILL.md +101 -101
- package/skills/openclaw-persona-forge/SKILL.md +289 -289
- package/skills/openclaw-persona-forge/references/avatar-style.md +124 -124
- package/skills/openclaw-persona-forge/references/boundary-rules.md +53 -53
- package/skills/openclaw-persona-forge/references/error-handling.md +53 -53
- package/skills/openclaw-persona-forge/references/identity-tension.md +48 -48
- package/skills/openclaw-persona-forge/references/naming-system.md +39 -39
- package/skills/openclaw-persona-forge/references/output-template.md +166 -166
- package/skills/opensource-pipeline/SKILL.md +256 -256
- package/skills/orch-add-feature/SKILL.md +45 -45
- package/skills/orch-build-mvp/SKILL.md +49 -49
- package/skills/orch-change-feature/SKILL.md +43 -43
- package/skills/orch-fix-defect/SKILL.md +43 -43
- package/skills/orch-pipeline/SKILL.md +121 -121
- package/skills/orch-refine-code/SKILL.md +44 -44
- package/skills/parallel-execution-optimizer/SKILL.md +74 -74
- package/skills/perl-patterns/SKILL.md +505 -505
- package/skills/perl-security/SKILL.md +504 -504
- package/skills/perl-testing/SKILL.md +476 -476
- package/skills/plan-canvas/SKILL.md +153 -153
- package/skills/plan-orchestrate/SKILL.md +263 -263
- package/skills/plankton-code-quality/SKILL.md +237 -237
- package/skills/postgres-patterns/SKILL.md +148 -148
- package/skills/prediction-market-oracle-research/SKILL.md +64 -64
- package/skills/prediction-market-risk-review/SKILL.md +61 -61
- package/skills/premium-web-design/SKILL.md +154 -154
- package/skills/prisma-patterns/SKILL.md +401 -401
- package/skills/product-capability/SKILL.md +142 -142
- package/skills/product-lens/SKILL.md +93 -93
- package/skills/production-audit/SKILL.md +207 -207
- package/skills/production-scheduling/SKILL.md +238 -238
- package/skills/project-flow-ops/SKILL.md +112 -112
- package/skills/prompt-optimizer/SKILL.md +398 -398
- package/skills/python-patterns/SKILL.md +751 -751
- package/skills/python-testing/SKILL.md +817 -817
- package/skills/pytorch-patterns/SKILL.md +397 -397
- package/skills/quality-nonconformance/SKILL.md +260 -260
- package/skills/quarkus-patterns/SKILL.md +723 -723
- package/skills/quarkus-security/SKILL.md +468 -468
- package/skills/quarkus-tdd/SKILL.md +812 -812
- package/skills/quarkus-verification/SKILL.md +480 -480
- package/skills/ralphinho-rfc-pipeline/SKILL.md +68 -68
- package/skills/react-native-patterns/SKILL.md +326 -326
- package/skills/react-patterns/SKILL.md +342 -342
- package/skills/react-performance/SKILL.md +575 -575
- package/skills/react-testing/SKILL.md +424 -424
- package/skills/recsys-pipeline-architect/SKILL.md +115 -115
- package/skills/recursive-decision-ledger/SKILL.md +81 -81
- package/skills/redis-patterns/SKILL.md +404 -404
- package/skills/regex-vs-llm-structured-text/SKILL.md +221 -221
- package/skills/remotion-video-creation/SKILL.md +43 -43
- package/skills/repo-scan/SKILL.md +79 -79
- package/skills/research-ops/SKILL.md +113 -113
- package/skills/returns-reverse-logistics/SKILL.md +240 -240
- package/skills/rules-distill/SKILL.md +265 -265
- package/skills/rust-patterns/SKILL.md +500 -500
- package/skills/rust-testing/SKILL.md +501 -501
- package/skills/safety-guard/SKILL.md +76 -76
- package/skills/santa-method/SKILL.md +307 -307
- package/skills/scientific-db-pubmed-database/SKILL.md +176 -176
- package/skills/scientific-db-uspto-database/SKILL.md +178 -178
- package/skills/scientific-pkg-gget/SKILL.md +167 -167
- package/skills/scientific-thinking-literature-review/SKILL.md +193 -193
- package/skills/scientific-thinking-scholar-evaluation/SKILL.md +161 -161
- package/skills/search-first/SKILL.md +183 -183
- package/skills/security-bounty-hunter/SKILL.md +100 -100
- package/skills/security-review/SKILL.md +504 -504
- package/skills/security-scan/SKILL.md +166 -166
- package/skills/seo/SKILL.md +155 -155
- package/skills/skill-comply/SKILL.md +59 -59
- package/skills/skill-scout/SKILL.md +141 -141
- package/skills/skill-stocktake/SKILL.md +195 -195
- package/skills/social-graph-ranker/SKILL.md +155 -155
- package/skills/social-publisher/SKILL.md +130 -130
- package/skills/springboot-patterns/SKILL.md +315 -315
- package/skills/springboot-security/SKILL.md +273 -273
- package/skills/springboot-tdd/SKILL.md +159 -159
- package/skills/springboot-verification/SKILL.md +232 -232
- package/skills/strategic-compact/SKILL.md +142 -142
- package/skills/swift-actor-persistence/SKILL.md +144 -144
- package/skills/swift-concurrency-6-2/SKILL.md +216 -216
- package/skills/swift-protocol-di-testing/SKILL.md +191 -191
- package/skills/swiftui-patterns/SKILL.md +259 -259
- package/skills/taste/SKILL.md +264 -264
- package/skills/taste/references/genre-taxonomy.md +87 -87
- package/skills/tdd-workflow/SKILL.md +583 -583
- package/skills/team-agent-orchestration/SKILL.md +111 -111
- package/skills/team-builder/SKILL.md +169 -169
- package/skills/terminal-ops/SKILL.md +110 -110
- package/skills/tinystruct-patterns/SKILL.md +279 -279
- package/skills/tinystruct-patterns/references/architecture.md +90 -90
- package/skills/tinystruct-patterns/references/data-handling.md +60 -60
- package/skills/tinystruct-patterns/references/database.md +99 -99
- package/skills/tinystruct-patterns/references/routing.md +64 -64
- package/skills/tinystruct-patterns/references/system-usage.md +97 -97
- package/skills/tinystruct-patterns/references/testing.md +72 -72
- package/skills/token-budget-advisor/SKILL.md +134 -134
- package/skills/ui-demo/SKILL.md +466 -466
- package/skills/ui-to-vue/SKILL.md +135 -135
- package/skills/uncloud/SKILL.md +344 -344
- package/skills/unified-memory/SKILL.md +170 -170
- package/skills/unified-notifications-ops/SKILL.md +188 -188
- package/skills/verification-loop/SKILL.md +129 -129
- package/skills/video-editing/SKILL.md +311 -311
- package/skills/videodb/SKILL.md +375 -375
- package/skills/visa-doc-translate/SKILL.md +117 -117
- package/skills/vite-patterns/SKILL.md +450 -450
- package/skills/vue-patterns/SKILL.md +471 -471
- package/skills/windows-desktop-e2e/SKILL.md +888 -888
- package/skills/workspace-surface-audit/SKILL.md +126 -126
- package/skills/x-api/SKILL.md +235 -235
package/lib/tools.mjs
CHANGED
|
@@ -1,2012 +1,2012 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Codeply agent tools.
|
|
3
|
-
*
|
|
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 -
|
|
6
|
-
* an untruncated file read is the fastest way to blow the context budget and
|
|
7
|
-
* make the agent stupid halfway through a task.
|
|
8
|
-
*
|
|
9
|
-
* Anything that changes the user's machine (write_file, edit_file, run) goes
|
|
10
|
-
* through ctx.approve() first and never side-steps it.
|
|
11
|
-
*/
|
|
12
|
-
import fs from 'fs';
|
|
13
|
-
import path from 'path';
|
|
14
|
-
import os from 'os';
|
|
15
|
-
import crypto from 'crypto';
|
|
16
|
-
import { exec } from 'child_process';
|
|
17
|
-
import { createRequire } from 'module';
|
|
18
|
-
import { searchLibrary, listCategories, designLibraryConfigured, CATEGORY_LABELS } from './design-library/query.mjs';
|
|
19
|
-
|
|
20
|
-
const require = createRequire(import.meta.url);
|
|
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');
|
|
26
|
-
const applyLimit = require('./apply-limit.js');
|
|
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');
|
|
32
|
-
|
|
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']);
|
|
36
|
-
|
|
37
|
-
const MAX_TOOL_OUTPUT = 12000; // chars fed back to the model per tool call
|
|
38
|
-
const MAX_READ_LINES = 600;
|
|
39
|
-
const MAX_SEARCH_HITS = 60;
|
|
40
|
-
const RUN_TIMEOUT_MS = 120000;
|
|
41
|
-
|
|
42
|
-
const IGNORED_DIRS = new Set([
|
|
43
|
-
'node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage',
|
|
44
|
-
'.cache', '.turbo', 'vendor', '__pycache__', '.venv', 'venv', 'target',
|
|
45
|
-
]);
|
|
46
|
-
|
|
47
|
-
const TEXT_EXT = new Set([
|
|
48
|
-
'.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.json', '.html', '.htm',
|
|
49
|
-
'.css', '.scss', '.sass', '.less', '.md', '.txt', '.py', '.rb', '.go',
|
|
50
|
-
'.rs', '.java', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.sh', '.yml',
|
|
51
|
-
'.yaml', '.toml', '.xml', '.svg', '.vue', '.svelte', '.sql', '.env',
|
|
52
|
-
]);
|
|
53
|
-
|
|
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 } = {}) {
|
|
86
|
-
if (text.length <= max) return text;
|
|
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}]`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Resolve a model-supplied path against cwd and report whether it escapes it. */
|
|
100
|
-
function resolvePath(p, cwd) {
|
|
101
|
-
const abs = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
|
|
102
|
-
const rel = path.relative(cwd, abs);
|
|
103
|
-
const outside = rel.startsWith('..') || path.isAbsolute(rel);
|
|
104
|
-
return { abs, rel: outside ? abs : rel.replace(/\\/g, '/') || '.', outside };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Minimal glob -> RegExp. `**` crosses separators, `*` and `?` do not. */
|
|
108
|
-
function globToRegex(glob) {
|
|
109
|
-
let re = '';
|
|
110
|
-
for (let i = 0; i < glob.length; i++) {
|
|
111
|
-
const c = glob[i];
|
|
112
|
-
if (c === '*') {
|
|
113
|
-
if (glob[i + 1] === '*') {
|
|
114
|
-
i++;
|
|
115
|
-
if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; }
|
|
116
|
-
else re += '.*';
|
|
117
|
-
} else {
|
|
118
|
-
re += '[^/]*';
|
|
119
|
-
}
|
|
120
|
-
} else if (c === '?') {
|
|
121
|
-
re += '[^/]';
|
|
122
|
-
} else if ('.+^${}()|[]'.includes(c) || c === '\\') {
|
|
123
|
-
re += '\\' + c;
|
|
124
|
-
} else {
|
|
125
|
-
re += c;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return new RegExp(`^${re}$`, 'i');
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Depth-first file walk that skips build/vendor noise and bails out early. */
|
|
132
|
-
function walkFiles(root, { maxFiles = 4000, maxDepth = 12 } = {}) {
|
|
133
|
-
const out = [];
|
|
134
|
-
const stack = [{ dir: root, depth: 0 }];
|
|
135
|
-
while (stack.length && out.length < maxFiles) {
|
|
136
|
-
const { dir, depth } = stack.pop();
|
|
137
|
-
if (depth > maxDepth) continue;
|
|
138
|
-
let entries;
|
|
139
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
140
|
-
for (const entry of entries) {
|
|
141
|
-
if (entry.name.startsWith('.') && entry.name !== '.env') {
|
|
142
|
-
if (entry.isDirectory()) continue;
|
|
143
|
-
}
|
|
144
|
-
const full = path.join(dir, entry.name);
|
|
145
|
-
if (entry.isDirectory()) {
|
|
146
|
-
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
147
|
-
stack.push({ dir: full, depth: depth + 1 });
|
|
148
|
-
} else if (entry.isFile()) {
|
|
149
|
-
out.push(full);
|
|
150
|
-
if (out.length >= maxFiles) break;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return out;
|
|
155
|
-
}
|
|
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
|
-
|
|
289
|
-
// ─── Tools ──────────────────────────────────────────────────────────────────
|
|
290
|
-
|
|
291
|
-
async function list_dir(args, ctx) {
|
|
292
|
-
const target = args.path || '.';
|
|
293
|
-
const { abs, rel } = resolvePath(target, ctx.cwd);
|
|
294
|
-
if (!fs.existsSync(abs)) return { ok: false, output: `No such directory: ${rel}` };
|
|
295
|
-
if (!fs.statSync(abs).isDirectory()) return { ok: false, output: `Not a directory: ${rel}` };
|
|
296
|
-
|
|
297
|
-
let entries;
|
|
298
|
-
try { entries = fs.readdirSync(abs, { withFileTypes: true }); }
|
|
299
|
-
catch (e) { return { ok: false, output: `Cannot read ${rel}: ${e.message}` }; }
|
|
300
|
-
|
|
301
|
-
const dirs = [];
|
|
302
|
-
const files = [];
|
|
303
|
-
for (const e of entries) {
|
|
304
|
-
if (e.isDirectory()) {
|
|
305
|
-
dirs.push(IGNORED_DIRS.has(e.name) ? `${e.name}/ (skipped)` : `${e.name}/`);
|
|
306
|
-
} else if (e.isFile()) {
|
|
307
|
-
let size = '';
|
|
308
|
-
try { size = ` ${fs.statSync(path.join(abs, e.name)).size}b`; } catch {}
|
|
309
|
-
files.push(`${e.name}${size}`);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
dirs.sort(); files.sort();
|
|
313
|
-
const body = [...dirs, ...files].join('\n') || '(empty)';
|
|
314
|
-
return { ok: true, output: truncate(`${rel}/\n${body}`), meta: { label: rel, count: dirs.length + files.length } };
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
async function read_file(args, ctx) {
|
|
318
|
-
const target = args.path;
|
|
319
|
-
if (!target) return { ok: false, output: 'read_file needs a <path>.' };
|
|
320
|
-
const { abs, rel } = resolvePath(target, ctx.cwd);
|
|
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.` };
|
|
326
|
-
|
|
327
|
-
let buf;
|
|
328
|
-
try { buf = fs.readFileSync(abs); }
|
|
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');
|
|
334
|
-
|
|
335
|
-
const allLines = content.split('\n');
|
|
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
|
-
}
|
|
340
|
-
const limit = Math.min(parseInt(args.limit, 10) || MAX_READ_LINES, MAX_READ_LINES);
|
|
341
|
-
const slice = allLines.slice(offset, offset + limit);
|
|
342
|
-
const width = String(offset + slice.length).length;
|
|
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.
|
|
346
|
-
const numbered = slice
|
|
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
|
-
})
|
|
352
|
-
.join('\n');
|
|
353
|
-
const more = allLines.length > offset + slice.length
|
|
354
|
-
? `\n[… ${allLines.length - offset - slice.length} more lines. Re-read with offset=${offset + slice.length}.]`
|
|
355
|
-
: '';
|
|
356
|
-
|
|
357
|
-
rememberDiskState(ctx, abs);
|
|
358
|
-
return {
|
|
359
|
-
ok: true,
|
|
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 },
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
async function write_file(args, ctx) {
|
|
371
|
-
const target = args.path;
|
|
372
|
-
if (!target) return { ok: false, output: 'write_file needs a <path>.' };
|
|
373
|
-
if (args.content == null) return { ok: false, output: 'write_file needs a <content> block.' };
|
|
374
|
-
|
|
375
|
-
const { abs, rel, outside } = resolvePath(target, ctx.cwd);
|
|
376
|
-
const existed = fs.existsSync(abs);
|
|
377
|
-
const before = existed ? fs.readFileSync(abs, 'utf8') : '';
|
|
378
|
-
const after = args.content;
|
|
379
|
-
if (existed && before === after) {
|
|
380
|
-
return { ok: true, output: `${rel} already has exactly this content - nothing written.`, meta: { label: rel, noop: true } };
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const beforeLines = existed ? before.split('\n').length : 0;
|
|
384
|
-
const afterLines = after.split('\n').length;
|
|
385
|
-
|
|
386
|
-
const verdict = ctx.mode === 'Plan' && isPlanPath(target, ctx.cwd) ? 'allow' : await ctx.approve({
|
|
387
|
-
tool: 'write_file',
|
|
388
|
-
path: rel,
|
|
389
|
-
title: existed ? `Overwrite ${rel}` : `Create ${rel}`,
|
|
390
|
-
detail: existed
|
|
391
|
-
? `${beforeLines} lines → ${afterLines} lines`
|
|
392
|
-
: `${afterLines} lines`,
|
|
393
|
-
danger: outside,
|
|
394
|
-
});
|
|
395
|
-
if (verdict === 'reject') return { ok: false, output: `User declined the write to ${rel}.`, meta: { rejected: true } };
|
|
396
|
-
|
|
397
|
-
try {
|
|
398
|
-
const dir = path.dirname(abs);
|
|
399
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
400
|
-
fs.writeFileSync(abs, after, 'utf8');
|
|
401
|
-
} catch (e) {
|
|
402
|
-
return { ok: false, output: `Cannot write ${rel}: ${e.message}` };
|
|
403
|
-
}
|
|
404
|
-
rememberDiskState(ctx, abs);
|
|
405
|
-
const problems = await diagnostics.checkFile(abs, ctx.cwd);
|
|
406
|
-
|
|
407
|
-
return {
|
|
408
|
-
ok: 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 },
|
|
412
|
-
};
|
|
413
|
-
}
|
|
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
|
-
|
|
421
|
-
async function edit_file(args, ctx) {
|
|
422
|
-
const target = args.path;
|
|
423
|
-
if (!target) return { ok: false, output: 'edit_file needs a <path>.' };
|
|
424
|
-
if (!args.search) return { ok: false, output: 'edit_file needs a <search> block.' };
|
|
425
|
-
if (args.replace == null) return { ok: false, output: 'edit_file needs a <replace> block.' };
|
|
426
|
-
|
|
427
|
-
const { abs, rel, outside } = resolvePath(target, ctx.cwd);
|
|
428
|
-
if (!fs.existsSync(abs)) return { ok: false, output: `No such file: ${rel}. Use write_file to create it.` };
|
|
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
|
-
|
|
439
|
-
const before = fs.readFileSync(abs, 'utf8');
|
|
440
|
-
const replaceAll = isTruthy(args.all);
|
|
441
|
-
// Dry-run through the same matcher that will do the real edit, so the user
|
|
442
|
-
// is never asked to approve something that turns out not to apply.
|
|
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
|
-
}
|
|
459
|
-
if (!attempt.ok) {
|
|
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}` };
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const removed = args.search.split('\n').length * occurrences;
|
|
474
|
-
const added = args.replace.split('\n').length * occurrences;
|
|
475
|
-
|
|
476
|
-
const verdict = ctx.mode === 'Plan' && isPlanPath(target, ctx.cwd) ? 'allow' : await ctx.approve({
|
|
477
|
-
tool: 'edit_file',
|
|
478
|
-
path: rel,
|
|
479
|
-
title: `Edit ${rel}`,
|
|
480
|
-
detail: `-${removed} +${added} lines`,
|
|
481
|
-
diff: { search: args.search, replace: args.replace },
|
|
482
|
-
danger: outside,
|
|
483
|
-
});
|
|
484
|
-
if (verdict === 'reject') return { ok: false, output: `User declined the edit to ${rel}.`, meta: { rejected: true } };
|
|
485
|
-
|
|
486
|
-
try { fs.writeFileSync(abs, attempt.content, 'utf8'); }
|
|
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);
|
|
490
|
-
|
|
491
|
-
const nowLines = attempt.content.split('\n').length;
|
|
492
|
-
return {
|
|
493
|
-
ok: true,
|
|
494
|
-
// The "do not re-read" line is load-bearing: without it the model burns a
|
|
495
|
-
// whole extra round-trip reading the file back, and stuffs the entire file
|
|
496
|
-
// into context again, which slows down every following step.
|
|
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 },
|
|
502
|
-
};
|
|
503
|
-
}
|
|
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
|
-
*/
|
|
518
|
-
async function search(args, ctx) {
|
|
519
|
-
const pattern = args.pattern;
|
|
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.' };
|
|
522
|
-
|
|
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
|
-
}
|
|
528
|
-
|
|
529
|
-
const root = args.path ? resolvePath(args.path, ctx.cwd).abs : ctx.cwd;
|
|
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);
|
|
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);
|
|
553
|
-
const hits = [];
|
|
554
|
-
const fileCounts = [];
|
|
555
|
-
let totalHits = 0;
|
|
556
|
-
let scanned = 0;
|
|
557
|
-
for (const file of files) {
|
|
558
|
-
const rel = path.relative(ctx.cwd, file).replace(/\\/g, '/');
|
|
559
|
-
if (globRe && !globRe.test(rel) && !globRe.test(rel.split('/').pop())) continue;
|
|
560
|
-
const ext = path.extname(file).toLowerCase();
|
|
561
|
-
if (ext && !TEXT_EXT.has(ext)) continue;
|
|
562
|
-
let content;
|
|
563
|
-
try {
|
|
564
|
-
if (fs.statSync(file).size > 2_000_000) continue;
|
|
565
|
-
content = fs.readFileSync(file, 'utf8');
|
|
566
|
-
} catch { continue; }
|
|
567
|
-
if (content.includes('\u0000')) continue; // binary that happens to have a text extension
|
|
568
|
-
scanned++;
|
|
569
|
-
const lines = content.split('\n');
|
|
570
|
-
let inFile = 0;
|
|
571
|
-
let lastShown = -1;
|
|
572
|
-
for (let i = 0; i < lines.length; i++) {
|
|
573
|
-
if (!re.test(lines[i])) continue;
|
|
574
|
-
inFile++;
|
|
575
|
-
totalHits++;
|
|
576
|
-
if (filesOnly || hits.length >= maxLines) continue;
|
|
577
|
-
if (!context) {
|
|
578
|
-
hits.push(`${rel}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
|
|
579
|
-
continue;
|
|
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;
|
|
591
|
-
}
|
|
592
|
-
if (inFile) fileCounts.push(`${rel} (${inFile})`);
|
|
593
|
-
if (!filesOnly && hits.length >= maxLines) break;
|
|
594
|
-
}
|
|
595
|
-
|
|
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' : ''}:`
|
|
606
|
-
: `No matches for /${pattern}/ across ${scanned} file(s).`;
|
|
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}>"`);
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
async function run(args, ctx) {
|
|
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);
|
|
1246
|
-
|
|
1247
|
-
const verdict = await ctx.approve({
|
|
1248
|
-
tool: 'run',
|
|
1249
|
-
title: 'Run command',
|
|
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),
|
|
1254
|
-
});
|
|
1255
|
-
if (verdict === 'reject') return { ok: false, output: `User declined to run: ${displayCommand}`, meta: { rejected: true } };
|
|
1256
|
-
|
|
1257
|
-
return new Promise((resolve) => {
|
|
1258
|
-
exec(execCommand, { cwd: ctx.cwd, timeout: RUN_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
|
|
1259
|
-
(err, stdout, stderr) => {
|
|
1260
|
-
const code = err ? (err.code == null ? 1 : err.code) : 0;
|
|
1261
|
-
const parts = [];
|
|
1262
|
-
if (stdout && stdout.trim()) parts.push(stdout.trimEnd());
|
|
1263
|
-
if (stderr && stderr.trim()) parts.push(`[stderr]\n${stderr.trimEnd()}`);
|
|
1264
|
-
if (err && err.killed) parts.push(`[timed out after ${RUN_TIMEOUT_MS / 1000}s]`);
|
|
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;
|
|
1274
|
-
resolve({
|
|
1275
|
-
ok: true, // a non-zero exit is a real result the model must see, not a tool failure
|
|
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 } : {}) },
|
|
1278
|
-
});
|
|
1279
|
-
});
|
|
1280
|
-
});
|
|
1281
|
-
}
|
|
1282
|
-
|
|
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
|
-
};
|
|
1936
|
-
|
|
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']);
|
|
1942
|
-
|
|
1943
|
-
/** Human-facing verb + colour hint for the transcript. */
|
|
1944
|
-
export const TOOL_DISPLAY = {
|
|
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: '▸' },
|
|
1955
|
-
read_file: { verb: 'read', icon: '▸' },
|
|
1956
|
-
write_file: { verb: 'write', icon: '✎' },
|
|
1957
|
-
edit_file: { verb: 'edit', icon: '✎' },
|
|
1958
|
-
search: { verb: 'search', icon: '▸' },
|
|
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: '▲' },
|
|
1976
|
-
};
|
|
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
|
-
|
|
1986
|
-
export async function executeTool(name, args, ctx) {
|
|
1987
|
-
const fn = TOOLS[name];
|
|
1988
|
-
if (!fn) {
|
|
1989
|
-
return { ok: false, output: `Unknown tool "${name}". Available: ${Object.keys(TOOLS).join(', ')}.` };
|
|
1990
|
-
}
|
|
1991
|
-
|
|
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.` };
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
const usingHosted = ctx.route ? !!ctx.route.auto : config.getConfig().provider === 'codeply';
|
|
1998
|
-
|
|
1999
|
-
try {
|
|
2000
|
-
const result = await fn(args, ctx);
|
|
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);
|
|
2005
|
-
}
|
|
2006
|
-
return result;
|
|
2007
|
-
} catch (e) {
|
|
2008
|
-
return { ok: false, output: `${name} failed: ${e.message}` };
|
|
2009
|
-
}
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
export { resolvePath, walkFiles, truncate, isPlanPath };
|
|
1
|
+
/**
|
|
2
|
+
* Codeply agent tools.
|
|
3
|
+
*
|
|
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 -
|
|
6
|
+
* an untruncated file read is the fastest way to blow the context budget and
|
|
7
|
+
* make the agent stupid halfway through a task.
|
|
8
|
+
*
|
|
9
|
+
* Anything that changes the user's machine (write_file, edit_file, run) goes
|
|
10
|
+
* through ctx.approve() first and never side-steps it.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import crypto from 'crypto';
|
|
16
|
+
import { exec } from 'child_process';
|
|
17
|
+
import { createRequire } from 'module';
|
|
18
|
+
import { searchLibrary, listCategories, designLibraryConfigured, CATEGORY_LABELS } from './design-library/query.mjs';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
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');
|
|
26
|
+
const applyLimit = require('./apply-limit.js');
|
|
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');
|
|
32
|
+
|
|
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']);
|
|
36
|
+
|
|
37
|
+
const MAX_TOOL_OUTPUT = 12000; // chars fed back to the model per tool call
|
|
38
|
+
const MAX_READ_LINES = 600;
|
|
39
|
+
const MAX_SEARCH_HITS = 60;
|
|
40
|
+
const RUN_TIMEOUT_MS = 120000;
|
|
41
|
+
|
|
42
|
+
const IGNORED_DIRS = new Set([
|
|
43
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage',
|
|
44
|
+
'.cache', '.turbo', 'vendor', '__pycache__', '.venv', 'venv', 'target',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const TEXT_EXT = new Set([
|
|
48
|
+
'.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.json', '.html', '.htm',
|
|
49
|
+
'.css', '.scss', '.sass', '.less', '.md', '.txt', '.py', '.rb', '.go',
|
|
50
|
+
'.rs', '.java', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.sh', '.yml',
|
|
51
|
+
'.yaml', '.toml', '.xml', '.svg', '.vue', '.svelte', '.sql', '.env',
|
|
52
|
+
]);
|
|
53
|
+
|
|
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 } = {}) {
|
|
86
|
+
if (text.length <= max) return text;
|
|
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}]`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Resolve a model-supplied path against cwd and report whether it escapes it. */
|
|
100
|
+
function resolvePath(p, cwd) {
|
|
101
|
+
const abs = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
|
|
102
|
+
const rel = path.relative(cwd, abs);
|
|
103
|
+
const outside = rel.startsWith('..') || path.isAbsolute(rel);
|
|
104
|
+
return { abs, rel: outside ? abs : rel.replace(/\\/g, '/') || '.', outside };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Minimal glob -> RegExp. `**` crosses separators, `*` and `?` do not. */
|
|
108
|
+
function globToRegex(glob) {
|
|
109
|
+
let re = '';
|
|
110
|
+
for (let i = 0; i < glob.length; i++) {
|
|
111
|
+
const c = glob[i];
|
|
112
|
+
if (c === '*') {
|
|
113
|
+
if (glob[i + 1] === '*') {
|
|
114
|
+
i++;
|
|
115
|
+
if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; }
|
|
116
|
+
else re += '.*';
|
|
117
|
+
} else {
|
|
118
|
+
re += '[^/]*';
|
|
119
|
+
}
|
|
120
|
+
} else if (c === '?') {
|
|
121
|
+
re += '[^/]';
|
|
122
|
+
} else if ('.+^${}()|[]'.includes(c) || c === '\\') {
|
|
123
|
+
re += '\\' + c;
|
|
124
|
+
} else {
|
|
125
|
+
re += c;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return new RegExp(`^${re}$`, 'i');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Depth-first file walk that skips build/vendor noise and bails out early. */
|
|
132
|
+
function walkFiles(root, { maxFiles = 4000, maxDepth = 12 } = {}) {
|
|
133
|
+
const out = [];
|
|
134
|
+
const stack = [{ dir: root, depth: 0 }];
|
|
135
|
+
while (stack.length && out.length < maxFiles) {
|
|
136
|
+
const { dir, depth } = stack.pop();
|
|
137
|
+
if (depth > maxDepth) continue;
|
|
138
|
+
let entries;
|
|
139
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (entry.name.startsWith('.') && entry.name !== '.env') {
|
|
142
|
+
if (entry.isDirectory()) continue;
|
|
143
|
+
}
|
|
144
|
+
const full = path.join(dir, entry.name);
|
|
145
|
+
if (entry.isDirectory()) {
|
|
146
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
147
|
+
stack.push({ dir: full, depth: depth + 1 });
|
|
148
|
+
} else if (entry.isFile()) {
|
|
149
|
+
out.push(full);
|
|
150
|
+
if (out.length >= maxFiles) break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
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
|
+
|
|
289
|
+
// ─── Tools ──────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
async function list_dir(args, ctx) {
|
|
292
|
+
const target = args.path || '.';
|
|
293
|
+
const { abs, rel } = resolvePath(target, ctx.cwd);
|
|
294
|
+
if (!fs.existsSync(abs)) return { ok: false, output: `No such directory: ${rel}` };
|
|
295
|
+
if (!fs.statSync(abs).isDirectory()) return { ok: false, output: `Not a directory: ${rel}` };
|
|
296
|
+
|
|
297
|
+
let entries;
|
|
298
|
+
try { entries = fs.readdirSync(abs, { withFileTypes: true }); }
|
|
299
|
+
catch (e) { return { ok: false, output: `Cannot read ${rel}: ${e.message}` }; }
|
|
300
|
+
|
|
301
|
+
const dirs = [];
|
|
302
|
+
const files = [];
|
|
303
|
+
for (const e of entries) {
|
|
304
|
+
if (e.isDirectory()) {
|
|
305
|
+
dirs.push(IGNORED_DIRS.has(e.name) ? `${e.name}/ (skipped)` : `${e.name}/`);
|
|
306
|
+
} else if (e.isFile()) {
|
|
307
|
+
let size = '';
|
|
308
|
+
try { size = ` ${fs.statSync(path.join(abs, e.name)).size}b`; } catch {}
|
|
309
|
+
files.push(`${e.name}${size}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
dirs.sort(); files.sort();
|
|
313
|
+
const body = [...dirs, ...files].join('\n') || '(empty)';
|
|
314
|
+
return { ok: true, output: truncate(`${rel}/\n${body}`), meta: { label: rel, count: dirs.length + files.length } };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function read_file(args, ctx) {
|
|
318
|
+
const target = args.path;
|
|
319
|
+
if (!target) return { ok: false, output: 'read_file needs a <path>.' };
|
|
320
|
+
const { abs, rel } = resolvePath(target, ctx.cwd);
|
|
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.` };
|
|
326
|
+
|
|
327
|
+
let buf;
|
|
328
|
+
try { buf = fs.readFileSync(abs); }
|
|
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');
|
|
334
|
+
|
|
335
|
+
const allLines = content.split('\n');
|
|
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
|
+
}
|
|
340
|
+
const limit = Math.min(parseInt(args.limit, 10) || MAX_READ_LINES, MAX_READ_LINES);
|
|
341
|
+
const slice = allLines.slice(offset, offset + limit);
|
|
342
|
+
const width = String(offset + slice.length).length;
|
|
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.
|
|
346
|
+
const numbered = slice
|
|
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
|
+
})
|
|
352
|
+
.join('\n');
|
|
353
|
+
const more = allLines.length > offset + slice.length
|
|
354
|
+
? `\n[… ${allLines.length - offset - slice.length} more lines. Re-read with offset=${offset + slice.length}.]`
|
|
355
|
+
: '';
|
|
356
|
+
|
|
357
|
+
rememberDiskState(ctx, abs);
|
|
358
|
+
return {
|
|
359
|
+
ok: true,
|
|
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 },
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function write_file(args, ctx) {
|
|
371
|
+
const target = args.path;
|
|
372
|
+
if (!target) return { ok: false, output: 'write_file needs a <path>.' };
|
|
373
|
+
if (args.content == null) return { ok: false, output: 'write_file needs a <content> block.' };
|
|
374
|
+
|
|
375
|
+
const { abs, rel, outside } = resolvePath(target, ctx.cwd);
|
|
376
|
+
const existed = fs.existsSync(abs);
|
|
377
|
+
const before = existed ? fs.readFileSync(abs, 'utf8') : '';
|
|
378
|
+
const after = args.content;
|
|
379
|
+
if (existed && before === after) {
|
|
380
|
+
return { ok: true, output: `${rel} already has exactly this content - nothing written.`, meta: { label: rel, noop: true } };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const beforeLines = existed ? before.split('\n').length : 0;
|
|
384
|
+
const afterLines = after.split('\n').length;
|
|
385
|
+
|
|
386
|
+
const verdict = ctx.mode === 'Plan' && isPlanPath(target, ctx.cwd) ? 'allow' : await ctx.approve({
|
|
387
|
+
tool: 'write_file',
|
|
388
|
+
path: rel,
|
|
389
|
+
title: existed ? `Overwrite ${rel}` : `Create ${rel}`,
|
|
390
|
+
detail: existed
|
|
391
|
+
? `${beforeLines} lines → ${afterLines} lines`
|
|
392
|
+
: `${afterLines} lines`,
|
|
393
|
+
danger: outside,
|
|
394
|
+
});
|
|
395
|
+
if (verdict === 'reject') return { ok: false, output: `User declined the write to ${rel}.`, meta: { rejected: true } };
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
const dir = path.dirname(abs);
|
|
399
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
400
|
+
fs.writeFileSync(abs, after, 'utf8');
|
|
401
|
+
} catch (e) {
|
|
402
|
+
return { ok: false, output: `Cannot write ${rel}: ${e.message}` };
|
|
403
|
+
}
|
|
404
|
+
rememberDiskState(ctx, abs);
|
|
405
|
+
const problems = await diagnostics.checkFile(abs, ctx.cwd);
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
ok: 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 },
|
|
412
|
+
};
|
|
413
|
+
}
|
|
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
|
+
|
|
421
|
+
async function edit_file(args, ctx) {
|
|
422
|
+
const target = args.path;
|
|
423
|
+
if (!target) return { ok: false, output: 'edit_file needs a <path>.' };
|
|
424
|
+
if (!args.search) return { ok: false, output: 'edit_file needs a <search> block.' };
|
|
425
|
+
if (args.replace == null) return { ok: false, output: 'edit_file needs a <replace> block.' };
|
|
426
|
+
|
|
427
|
+
const { abs, rel, outside } = resolvePath(target, ctx.cwd);
|
|
428
|
+
if (!fs.existsSync(abs)) return { ok: false, output: `No such file: ${rel}. Use write_file to create it.` };
|
|
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
|
+
|
|
439
|
+
const before = fs.readFileSync(abs, 'utf8');
|
|
440
|
+
const replaceAll = isTruthy(args.all);
|
|
441
|
+
// Dry-run through the same matcher that will do the real edit, so the user
|
|
442
|
+
// is never asked to approve something that turns out not to apply.
|
|
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
|
+
}
|
|
459
|
+
if (!attempt.ok) {
|
|
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}` };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const removed = args.search.split('\n').length * occurrences;
|
|
474
|
+
const added = args.replace.split('\n').length * occurrences;
|
|
475
|
+
|
|
476
|
+
const verdict = ctx.mode === 'Plan' && isPlanPath(target, ctx.cwd) ? 'allow' : await ctx.approve({
|
|
477
|
+
tool: 'edit_file',
|
|
478
|
+
path: rel,
|
|
479
|
+
title: `Edit ${rel}`,
|
|
480
|
+
detail: `-${removed} +${added} lines`,
|
|
481
|
+
diff: { search: args.search, replace: args.replace },
|
|
482
|
+
danger: outside,
|
|
483
|
+
});
|
|
484
|
+
if (verdict === 'reject') return { ok: false, output: `User declined the edit to ${rel}.`, meta: { rejected: true } };
|
|
485
|
+
|
|
486
|
+
try { fs.writeFileSync(abs, attempt.content, 'utf8'); }
|
|
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);
|
|
490
|
+
|
|
491
|
+
const nowLines = attempt.content.split('\n').length;
|
|
492
|
+
return {
|
|
493
|
+
ok: true,
|
|
494
|
+
// The "do not re-read" line is load-bearing: without it the model burns a
|
|
495
|
+
// whole extra round-trip reading the file back, and stuffs the entire file
|
|
496
|
+
// into context again, which slows down every following step.
|
|
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 },
|
|
502
|
+
};
|
|
503
|
+
}
|
|
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
|
+
*/
|
|
518
|
+
async function search(args, ctx) {
|
|
519
|
+
const pattern = args.pattern;
|
|
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.' };
|
|
522
|
+
|
|
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
|
+
}
|
|
528
|
+
|
|
529
|
+
const root = args.path ? resolvePath(args.path, ctx.cwd).abs : ctx.cwd;
|
|
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);
|
|
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);
|
|
553
|
+
const hits = [];
|
|
554
|
+
const fileCounts = [];
|
|
555
|
+
let totalHits = 0;
|
|
556
|
+
let scanned = 0;
|
|
557
|
+
for (const file of files) {
|
|
558
|
+
const rel = path.relative(ctx.cwd, file).replace(/\\/g, '/');
|
|
559
|
+
if (globRe && !globRe.test(rel) && !globRe.test(rel.split('/').pop())) continue;
|
|
560
|
+
const ext = path.extname(file).toLowerCase();
|
|
561
|
+
if (ext && !TEXT_EXT.has(ext)) continue;
|
|
562
|
+
let content;
|
|
563
|
+
try {
|
|
564
|
+
if (fs.statSync(file).size > 2_000_000) continue;
|
|
565
|
+
content = fs.readFileSync(file, 'utf8');
|
|
566
|
+
} catch { continue; }
|
|
567
|
+
if (content.includes('\u0000')) continue; // binary that happens to have a text extension
|
|
568
|
+
scanned++;
|
|
569
|
+
const lines = content.split('\n');
|
|
570
|
+
let inFile = 0;
|
|
571
|
+
let lastShown = -1;
|
|
572
|
+
for (let i = 0; i < lines.length; i++) {
|
|
573
|
+
if (!re.test(lines[i])) continue;
|
|
574
|
+
inFile++;
|
|
575
|
+
totalHits++;
|
|
576
|
+
if (filesOnly || hits.length >= maxLines) continue;
|
|
577
|
+
if (!context) {
|
|
578
|
+
hits.push(`${rel}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
|
|
579
|
+
continue;
|
|
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;
|
|
591
|
+
}
|
|
592
|
+
if (inFile) fileCounts.push(`${rel} (${inFile})`);
|
|
593
|
+
if (!filesOnly && hits.length >= maxLines) break;
|
|
594
|
+
}
|
|
595
|
+
|
|
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' : ''}:`
|
|
606
|
+
: `No matches for /${pattern}/ across ${scanned} file(s).`;
|
|
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}>"`);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function run(args, ctx) {
|
|
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);
|
|
1246
|
+
|
|
1247
|
+
const verdict = await ctx.approve({
|
|
1248
|
+
tool: 'run',
|
|
1249
|
+
title: 'Run command',
|
|
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),
|
|
1254
|
+
});
|
|
1255
|
+
if (verdict === 'reject') return { ok: false, output: `User declined to run: ${displayCommand}`, meta: { rejected: true } };
|
|
1256
|
+
|
|
1257
|
+
return new Promise((resolve) => {
|
|
1258
|
+
exec(execCommand, { cwd: ctx.cwd, timeout: RUN_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
|
|
1259
|
+
(err, stdout, stderr) => {
|
|
1260
|
+
const code = err ? (err.code == null ? 1 : err.code) : 0;
|
|
1261
|
+
const parts = [];
|
|
1262
|
+
if (stdout && stdout.trim()) parts.push(stdout.trimEnd());
|
|
1263
|
+
if (stderr && stderr.trim()) parts.push(`[stderr]\n${stderr.trimEnd()}`);
|
|
1264
|
+
if (err && err.killed) parts.push(`[timed out after ${RUN_TIMEOUT_MS / 1000}s]`);
|
|
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;
|
|
1274
|
+
resolve({
|
|
1275
|
+
ok: true, // a non-zero exit is a real result the model must see, not a tool failure
|
|
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 } : {}) },
|
|
1278
|
+
});
|
|
1279
|
+
});
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
|
|
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
|
+
};
|
|
1936
|
+
|
|
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']);
|
|
1942
|
+
|
|
1943
|
+
/** Human-facing verb + colour hint for the transcript. */
|
|
1944
|
+
export const TOOL_DISPLAY = {
|
|
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: '▸' },
|
|
1955
|
+
read_file: { verb: 'read', icon: '▸' },
|
|
1956
|
+
write_file: { verb: 'write', icon: '✎' },
|
|
1957
|
+
edit_file: { verb: 'edit', icon: '✎' },
|
|
1958
|
+
search: { verb: 'search', icon: '▸' },
|
|
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: '▲' },
|
|
1976
|
+
};
|
|
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
|
+
|
|
1986
|
+
export async function executeTool(name, args, ctx) {
|
|
1987
|
+
const fn = TOOLS[name];
|
|
1988
|
+
if (!fn) {
|
|
1989
|
+
return { ok: false, output: `Unknown tool "${name}". Available: ${Object.keys(TOOLS).join(', ')}.` };
|
|
1990
|
+
}
|
|
1991
|
+
|
|
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.` };
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
const usingHosted = ctx.route ? !!ctx.route.auto : config.getConfig().provider === 'codeply';
|
|
1998
|
+
|
|
1999
|
+
try {
|
|
2000
|
+
const result = await fn(args, ctx);
|
|
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);
|
|
2005
|
+
}
|
|
2006
|
+
return result;
|
|
2007
|
+
} catch (e) {
|
|
2008
|
+
return { ok: false, output: `${name} failed: ${e.message}` };
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
export { resolvePath, walkFiles, truncate, isPlanPath };
|