rovecode 0.4.0-beta.2 → 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/README.md +67 -69
- package/THIRD_PARTY_NOTICES.md +0 -44
- package/bin/rovecode.ts +21 -0
- package/package.json +16 -37
- package/src/account/keys.ts +97 -0
- package/src/account/login.ts +158 -0
- package/src/account/provision.ts +47 -0
- package/src/account/store.ts +63 -0
- package/src/acp/server.ts +373 -0
- package/src/cli/account-cmd.ts +116 -0
- package/src/cli/connect.ts +244 -0
- package/src/cli/context-cmd.ts +199 -0
- package/src/cli/dispatch.ts +109 -0
- package/src/cli/doctor.ts +324 -0
- package/src/cli/export.ts +278 -0
- package/src/cli/help.ts +240 -0
- package/src/cli/is-tui-invocation.ts +8 -0
- package/src/cli/main.ts +599 -0
- package/src/cli/market-cmd.ts +658 -0
- package/src/cli/mcp-market-cmd.ts +299 -0
- package/src/cli/output.ts +382 -0
- package/src/cli/repl.ts +172 -0
- package/src/cli/resume.ts +32 -0
- package/src/cli/run-limits.ts +78 -0
- package/src/cli/runtime.ts +792 -0
- package/src/cli/setup.ts +187 -0
- package/src/cli/update-cmd.ts +78 -0
- package/src/cli/workflow-cmd.ts +100 -0
- package/src/coding/checkpoints.ts +270 -0
- package/src/coding/diff.ts +136 -0
- package/src/coding/files.ts +339 -0
- package/src/coding/hashline.ts +319 -0
- package/src/coding/lsp.ts +406 -0
- package/src/coding/repomap-cache.ts +99 -0
- package/src/coding/repomap-files.ts +110 -0
- package/src/coding/repomap.ts +392 -0
- package/src/core/compaction.ts +399 -0
- package/src/core/config.ts +289 -0
- package/src/core/context-report.ts +228 -0
- package/src/core/context.ts +60 -0
- package/src/core/count-remote.ts +107 -0
- package/src/core/execpolicy-rules.ts +196 -0
- package/src/core/execpolicy.ts +385 -0
- package/src/core/executor.ts +397 -0
- package/src/core/guardrails.ts +400 -0
- package/src/core/hooks.ts +398 -0
- package/src/core/images.ts +230 -0
- package/src/core/intro.ts +236 -0
- package/src/core/loop.ts +621 -0
- package/src/core/modes.ts +372 -0
- package/src/core/orchestrator.ts +207 -0
- package/src/core/reflection.ts +165 -0
- package/src/core/sandbox-config.ts +167 -0
- package/src/core/session-images.ts +73 -0
- package/src/core/session.ts +398 -0
- package/src/core/settings.ts +98 -0
- package/src/core/stuck-detector.ts +273 -0
- package/src/core/tasks.ts +374 -0
- package/src/core/token-scale.ts +108 -0
- package/src/core/tool-output-budget.ts +166 -0
- package/src/core/tools.ts +288 -0
- package/src/core/types.ts +330 -0
- package/src/core/update-check.ts +171 -0
- package/src/core/update.ts +158 -0
- package/src/core/usage.ts +204 -0
- package/src/core/validate.ts +121 -0
- package/src/core/verify-gate.ts +159 -0
- package/src/core/verify.ts +237 -0
- package/src/core/voice.ts +158 -0
- package/src/core/win-job.ts +183 -0
- package/src/design/audit.ts +797 -0
- package/src/design/direction.ts +190 -0
- package/src/design/rules.ts +157 -0
- package/src/eval/bench.ts +150 -0
- package/src/eval/gauntlet-runner.ts +218 -0
- package/src/eval/gauntlet.ts +226 -0
- package/src/eval/grader.ts +186 -0
- package/src/eval/record.ts +202 -0
- package/src/eval/redact.ts +141 -0
- package/src/eval/replay.ts +147 -0
- package/src/eval/trajectory.ts +373 -0
- package/src/index.ts +17 -0
- package/src/market/catalogs/mcp-docs.json +111 -0
- package/src/market/catalogs/plugins.json +111 -0
- package/src/market/catalogs/skills.json +478 -0
- package/src/market/clone.ts +72 -0
- package/src/market/context-cost.ts +121 -0
- package/src/market/digest.ts +106 -0
- package/src/market/index.ts +22 -0
- package/src/market/install.ts +578 -0
- package/src/market/manifest.ts +187 -0
- package/src/market/prereq.ts +145 -0
- package/src/market/registry.ts +363 -0
- package/src/market/resolve.ts +111 -0
- package/src/market/types.ts +236 -0
- package/src/market/validate.ts +227 -0
- package/src/mcp/client.ts +431 -0
- package/src/mcp/config.ts +239 -0
- package/src/mcp/local-package.ts +211 -0
- package/src/mcp/market-catalog.ts +84 -0
- package/src/mcp/market-install.ts +289 -0
- package/src/mcp/market.ts +0 -0
- package/src/mcp/tools.ts +131 -0
- package/src/mcp/trust.ts +49 -0
- package/src/memory/blocks.ts +175 -0
- package/src/memory/recall.ts +355 -0
- package/src/memory/store.ts +105 -0
- package/src/memory/tools.ts +99 -0
- package/src/plugins/cli.ts +123 -0
- package/src/plugins/discover.ts +108 -0
- package/src/plugins/index.ts +50 -0
- package/src/plugins/init.ts +140 -0
- package/src/plugins/install.ts +184 -0
- package/src/plugins/load.ts +149 -0
- package/src/plugins/manifest.ts +106 -0
- package/src/plugins/state.ts +83 -0
- package/src/providers/auth.ts +293 -0
- package/src/providers/cache.ts +223 -0
- package/src/providers/catalog-local.ts +160 -0
- package/src/providers/catalog.ts +408 -0
- package/src/providers/middleware-context.ts +86 -0
- package/src/providers/middleware.ts +373 -0
- package/src/providers/profile-glm53.ts +111 -0
- package/src/providers/profile-sonnet5-persona.ts +65 -0
- package/src/providers/profile-sonnet5-voice.ts +23 -0
- package/src/providers/profiles.ts +156 -0
- package/src/providers/provider-config.ts +311 -0
- package/src/providers/registry.ts +302 -0
- package/src/providers/response-validation.ts +80 -0
- package/src/providers/retry.ts +234 -0
- package/src/providers/router.ts +294 -0
- package/src/providers/sse.ts +26 -0
- package/src/providers/stream-errors.ts +117 -0
- package/src/providers/stream.ts +569 -0
- package/src/providers/thinking.ts +189 -0
- package/src/providers/wire-messages.ts +129 -0
- package/src/sdk/client.ts +225 -0
- package/src/sdk/index.ts +3 -0
- package/src/server/dashboard.ts +144 -0
- package/src/server/http.ts +343 -0
- package/src/server/openapi.ts +246 -0
- package/src/sextant/card-hits.ts +102 -0
- package/src/sextant/card-keys.ts +55 -0
- package/src/sextant/context-source.ts +157 -0
- package/src/sextant/draw-agents.ts +273 -0
- package/src/sextant/draw-code.ts +388 -0
- package/src/sextant/draw-context.ts +222 -0
- package/src/sextant/draw-frame.ts +164 -0
- package/src/sextant/draw-market.ts +573 -0
- package/src/sextant/draw-messages.ts +386 -0
- package/src/sextant/draw-pet.ts +230 -0
- package/src/sextant/draw-plan.ts +159 -0
- package/src/sextant/draw-tabs.ts +85 -0
- package/src/sextant/draw-util.ts +65 -0
- package/src/sextant/engine.ts +230 -0
- package/src/sextant/frame-hits.ts +25 -0
- package/src/sextant/frame.ts +101 -0
- package/src/sextant/git-status.ts +197 -0
- package/src/sextant/grid.ts +59 -0
- package/src/sextant/input.ts +119 -0
- package/src/sextant/keys.ts +488 -0
- package/src/sextant/layout.ts +86 -0
- package/src/sextant/local-commands.ts +156 -0
- package/src/sextant/market-source.ts +287 -0
- package/src/sextant/mentions.ts +141 -0
- package/src/sextant/message-hits.ts +26 -0
- package/src/sextant/model.ts +387 -0
- package/src/sextant/overlays.ts +451 -0
- package/src/sextant/panel-hits.ts +38 -0
- package/src/sextant/pet.ts +399 -0
- package/src/sextant/screen.ts +324 -0
- package/src/sextant/scroll-hits.ts +66 -0
- package/src/sextant/scrollbar.ts +82 -0
- package/src/sextant/selection.ts +123 -0
- package/src/sextant/sextant-bridge.ts +174 -0
- package/src/sextant/sextant-cards.ts +142 -0
- package/src/sextant/sextant-diff-base.ts +63 -0
- package/src/sextant/sextant-files.ts +154 -0
- package/src/sextant/sextant-frame-loop.ts +314 -0
- package/src/sextant/sextant-renderer.ts +478 -0
- package/src/sextant/sextant-repo.ts +131 -0
- package/src/sextant/theme.ts +66 -0
- package/src/sextant/tool-rows.ts +189 -0
- package/src/sextant/types.ts +473 -0
- package/src/skills/index.ts +306 -0
- package/src/skills/tools.ts +69 -0
- package/src/skills/versioned.ts +227 -0
- package/src/telemetry/otel.ts +353 -0
- package/src/telemetry/otlp.ts +68 -0
- package/src/tools/ask-user.ts +156 -0
- package/src/tools/design.ts +151 -0
- package/src/tools/evalcell.ts +338 -0
- package/src/tools/html-text.ts +139 -0
- package/src/tools/provider.ts +149 -0
- package/src/tools/task.ts +216 -0
- package/src/tools/todo.ts +320 -0
- package/src/tools/webfetch.ts +331 -0
- package/src/tui/app.ts +608 -0
- package/src/tui/attach.ts +127 -0
- package/src/tui/checkpoints-cmd.ts +70 -0
- package/src/tui/clipboard-image.ts +81 -0
- package/src/tui/commands.ts +277 -0
- package/src/tui/cost.ts +108 -0
- package/src/tui/info-cmd.ts +144 -0
- package/src/tui/mcp-cmd.ts +128 -0
- package/src/tui/modes-cmd.ts +45 -0
- package/src/tui/overlays.ts +97 -0
- package/src/tui/pi-renderer.ts +424 -0
- package/src/tui/providers-cmd.ts +366 -0
- package/src/tui/renderer.ts +101 -0
- package/src/tui/replay-marker.ts +29 -0
- package/src/tui/session-cmd.ts +146 -0
- package/src/tui/sextant-attach.ts +68 -0
- package/src/tui/sextant-io.ts +184 -0
- package/src/tui/sextant-smoke.ts +110 -0
- package/src/tui/smoke.ts +72 -0
- package/src/tui/theme.ts +59 -0
- package/src/tui/todo-label.ts +7 -0
- package/src/workflow/engine.ts +266 -0
- package/tsconfig.json +30 -0
- package/vendor/pi-tui/LICENSE +21 -0
- package/vendor/pi-tui/PATCHES.md +12 -0
- package/vendor/pi-tui/PROVENANCE.md +12 -0
- package/vendor/pi-tui/README.upstream.md +854 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
- package/vendor/pi-tui/src/autocomplete.ts +827 -0
- package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
- package/vendor/pi-tui/src/components/box.ts +138 -0
- package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
- package/vendor/pi-tui/src/components/editor.ts +2364 -0
- package/vendor/pi-tui/src/components/h-stack.ts +45 -0
- package/vendor/pi-tui/src/components/image.ts +128 -0
- package/vendor/pi-tui/src/components/input.ts +448 -0
- package/vendor/pi-tui/src/components/loader.ts +93 -0
- package/vendor/pi-tui/src/components/markdown.ts +1016 -0
- package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
- package/vendor/pi-tui/src/components/select-list.ts +230 -0
- package/vendor/pi-tui/src/components/settings-list.ts +277 -0
- package/vendor/pi-tui/src/components/spacer.ts +29 -0
- package/vendor/pi-tui/src/components/stack.ts +155 -0
- package/vendor/pi-tui/src/components/text.ts +108 -0
- package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
- package/vendor/pi-tui/src/components/v-stack.ts +34 -0
- package/vendor/pi-tui/src/editor-component.ts +75 -0
- package/vendor/pi-tui/src/fuzzy.ts +138 -0
- package/vendor/pi-tui/src/index.ts +149 -0
- package/vendor/pi-tui/src/keybindings.ts +321 -0
- package/vendor/pi-tui/src/keys.ts +1402 -0
- package/vendor/pi-tui/src/kill-ring.ts +47 -0
- package/vendor/pi-tui/src/latex.ts +1381 -0
- package/vendor/pi-tui/src/layout-node.ts +52 -0
- package/vendor/pi-tui/src/layout.ts +411 -0
- package/vendor/pi-tui/src/native-modifiers.ts +60 -0
- package/vendor/pi-tui/src/native-module-path.ts +32 -0
- package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
- package/vendor/pi-tui/src/terminal-colors.ts +74 -0
- package/vendor/pi-tui/src/terminal-image.ts +701 -0
- package/vendor/pi-tui/src/terminal.ts +554 -0
- package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
- package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
- package/vendor/pi-tui/src/tui.ts +1264 -0
- package/vendor/pi-tui/src/undo-stack.ts +29 -0
- package/vendor/pi-tui/src/utils.ts +1327 -0
- package/vendor/pi-tui/src/word-navigation.ts +118 -0
- package/vendor/pi-tui/test/test-themes.ts +39 -0
- package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
- package/CHANGELOG.md +0 -512
- package/bin/rovecode.js +0 -24
- package/dist/cli/app-dybnr56b.js +0 -2
- package/dist/cli/ask-user-p8hq4xgj.js +0 -2
- package/dist/cli/auth-login-ewpgw5sm.js +0 -2
- package/dist/cli/auth-m8p9grty.js +0 -2
- package/dist/cli/bench-xv3ypwev.js +0 -9
- package/dist/cli/catalog-737wb2s0.js +0 -2
- package/dist/cli/cli-arhg40m0.js +0 -2
- package/dist/cli/client-cf2pxx8q.js +0 -2
- package/dist/cli/commands-3p7e4xxs.js +0 -2
- package/dist/cli/connect-3q93d7cb.js +0 -2
- package/dist/cli/context-cmd-eqxmxhzq.js +0 -2
- package/dist/cli/context-report-hbw9zfes.js +0 -2
- package/dist/cli/count-remote-mby98cd0.js +0 -2
- package/dist/cli/design-122y0axd.js +0 -2
- package/dist/cli/dispatch-b4egzvvh.js +0 -2
- package/dist/cli/doctor-x4jkv72e.js +0 -3
- package/dist/cli/executor-ftvg6tsy.js +0 -2
- package/dist/cli/export-pdgdhkch.js +0 -2
- package/dist/cli/files-cez9a96p.js +0 -2
- package/dist/cli/gauntlet-r3xxaszc.js +0 -2
- package/dist/cli/gauntlet-runner-r515m7kk.js +0 -10
- package/dist/cli/gauntlet-wave3-bnkjk2v2.js +0 -5
- package/dist/cli/gauntlet-wave4-acs9s60q.js +0 -14
- package/dist/cli/hashline-ewg5hbe3.js +0 -2
- package/dist/cli/http-n0kehsk8.js +0 -5
- package/dist/cli/index-z5qt1s76.js +0 -2
- package/dist/cli/install-80mp63kx.js +0 -2
- package/dist/cli/loop-12twjcat.js +0 -2
- package/dist/cli/main-01pv9206.js +0 -4
- package/dist/cli/main-0jys2ccn.js +0 -3
- package/dist/cli/main-1ztz6fkj.js +0 -10
- package/dist/cli/main-23q7cmww.js +0 -9
- package/dist/cli/main-2rzbexn2.js +0 -3
- package/dist/cli/main-2wyax8k9.js +0 -9
- package/dist/cli/main-2yeveeve.js +0 -6
- package/dist/cli/main-2z3dek0b.js +0 -3
- package/dist/cli/main-2zgsknth.js +0 -3
- package/dist/cli/main-45ejth3a.js +0 -4
- package/dist/cli/main-45rn3trk.js +0 -22
- package/dist/cli/main-4p4e2w7x.js +0 -4
- package/dist/cli/main-4y0tnfpa.js +0 -16
- package/dist/cli/main-5py0rkmc.js +0 -4
- package/dist/cli/main-6dtqmbt6.js +0 -7
- package/dist/cli/main-6h9x282m.js +0 -4
- package/dist/cli/main-6vjeds42.js +0 -3
- package/dist/cli/main-78gq4bt9.js +0 -6
- package/dist/cli/main-7jd5vh3x.js +0 -4
- package/dist/cli/main-7kt6r53y.js +0 -4
- package/dist/cli/main-8c1tbazx.js +0 -58
- package/dist/cli/main-9a9rnh47.js +0 -19
- package/dist/cli/main-9ht36z12.js +0 -3
- package/dist/cli/main-a2yfvcy9.js +0 -7
- package/dist/cli/main-a3f51n0x.js +0 -5
- package/dist/cli/main-b8zq261k.js +0 -3
- package/dist/cli/main-bxtvnf6d.js +0 -13
- package/dist/cli/main-edxc3yzt.js +0 -4
- package/dist/cli/main-evgz4mp5.js +0 -21
- package/dist/cli/main-f33fc5je.js +0 -9
- package/dist/cli/main-fvnpq46y.js +0 -12
- package/dist/cli/main-gbbty4d4.js +0 -3
- package/dist/cli/main-gth53dnt.js +0 -25
- package/dist/cli/main-hqbz10aw.js +0 -9
- package/dist/cli/main-hrrvcfan.js +0 -38
- package/dist/cli/main-hzwtsb2m.js +0 -5
- package/dist/cli/main-j7ttv0sd.js +0 -34
- package/dist/cli/main-jak598k9.js +0 -5
- package/dist/cli/main-kba6zeyd.js +0 -6
- package/dist/cli/main-kwwsz6rq.js +0 -3
- package/dist/cli/main-m8vm17zq.js +0 -3
- package/dist/cli/main-mg4f96e1.js +0 -3
- package/dist/cli/main-mg9b20ac.js +0 -18
- package/dist/cli/main-mgb9ccnx.js +0 -3
- package/dist/cli/main-mjt2p7aj.js +0 -3
- package/dist/cli/main-n6qrdbmy.js +0 -3
- package/dist/cli/main-na7wse0x.js +0 -5
- package/dist/cli/main-nqveez48.js +0 -4
- package/dist/cli/main-ntqef02r.js +0 -10
- package/dist/cli/main-nvc3yjay.js +0 -136
- package/dist/cli/main-p0cfn6nr.js +0 -16
- package/dist/cli/main-qj2djy17.js +0 -19
- package/dist/cli/main-qsevpgsv.js +0 -3
- package/dist/cli/main-qvarybsp.js +0 -3
- package/dist/cli/main-rebtt91r.js +0 -5
- package/dist/cli/main-rpg7h8mb.js +0 -3
- package/dist/cli/main-rsy72qmw.js +0 -15
- package/dist/cli/main-rvetps99.js +0 -18
- package/dist/cli/main-s4bb0jav.js +0 -3
- package/dist/cli/main-s9v8k74e.js +0 -3
- package/dist/cli/main-tjvwmscs.js +0 -3
- package/dist/cli/main-tkgarpjj.js +0 -4
- package/dist/cli/main-v8y60bb2.js +0 -3
- package/dist/cli/main-vhrrq337.js +0 -3
- package/dist/cli/main-vp2dfb7s.js +0 -4
- package/dist/cli/main-vqbr22sz.js +0 -8
- package/dist/cli/main-vxnwe5xx.js +0 -18
- package/dist/cli/main-wgph00xf.js +0 -5
- package/dist/cli/main-wk2csfnj.js +0 -5
- package/dist/cli/main-wm997zjx.js +0 -3
- package/dist/cli/main-wpkyraxh.js +0 -3
- package/dist/cli/main-wqt32p5x.js +0 -4
- package/dist/cli/main-x9ct6y1a.js +0 -3
- package/dist/cli/main-xfekqh9m.js +0 -7
- package/dist/cli/main-xt9zc3n6.js +0 -7
- package/dist/cli/main-xx2z3zh5.js +0 -4
- package/dist/cli/main-y5c82rxr.js +0 -3
- package/dist/cli/main-yrjt2sqt.js +0 -14
- package/dist/cli/main-ys6zj3yr.js +0 -3
- package/dist/cli/main-ywbxshqc.js +0 -8
- package/dist/cli/main-z13755t8.js +0 -25
- package/dist/cli/main-zc7pyrbj.js +0 -4
- package/dist/cli/main.js +0 -279
- package/dist/cli/market-cmd-bm5xvn9f.js +0 -5
- package/dist/cli/mcp-login-bthtfpt7.js +0 -2
- package/dist/cli/mcp-market-cmd-mbeshfyd.js +0 -2
- package/dist/cli/notify-54v5z9dz.js +0 -2
- package/dist/cli/oauth-g5gme95c.js +0 -2
- package/dist/cli/output-satndjap.js +0 -16
- package/dist/cli/profiles-sfhpbq3m.js +0 -2
- package/dist/cli/provider-config-hv3xtdt4.js +0 -2
- package/dist/cli/provider-kwzq6g84.js +0 -2
- package/dist/cli/registry-fh0hdnyn.js +0 -2
- package/dist/cli/registry-y1y8e94r.js +0 -2
- package/dist/cli/repl-t4z03mqq.js +0 -11
- package/dist/cli/resume-fqt4chg8.js +0 -2
- package/dist/cli/run-flags-rysbag9t.js +0 -2
- package/dist/cli/runtime-j19fjbsa.js +0 -2
- package/dist/cli/sandbox-config-g4qxd7y5.js +0 -2
- package/dist/cli/server-r0b6bksk.js +0 -5
- package/dist/cli/session-arg-txmn5g4x.js +0 -2
- package/dist/cli/session-ed250d9j.js +0 -2
- package/dist/cli/sessions-cmd-adw7svfn.js +0 -7
- package/dist/cli/settings-y9rzcqx8.js +0 -2
- package/dist/cli/setup-jmbr11j0.js +0 -2
- package/dist/cli/sextant-smoke-tcth0vea.js +0 -5
- package/dist/cli/skills-cmd-zbdy99v6.js +0 -2
- package/dist/cli/smoke-1bg937kx.js +0 -8
- package/dist/cli/start-chat-p01cdks3.js +0 -12
- package/dist/cli/stream-4wmyaypz.js +0 -2
- package/dist/cli/task-eg4s093s.js +0 -2
- package/dist/cli/tasks-12v9rr9k.js +0 -2
- package/dist/cli/thinking-a5ngvqyh.js +0 -2
- package/dist/cli/todo-1wxpcecx.js +0 -2
- package/dist/cli/tools-2ftsya7w.js +0 -2
- package/dist/cli/tools-x1tj4fxm.js +0 -2
- package/dist/cli/trust-cmd-hccxehzb.js +0 -2
- package/dist/cli/update-check-ygt3vd7m.js +0 -2
- package/dist/cli/update-cmd-v23qhr8c.js +0 -2
- package/dist/cli/voice-g1gtck92.js +0 -2
- package/dist/cli/webfetch-0nnrjgb5.js +0 -2
- package/dist/cli/websearch-f0vr2p7d.js +0 -2
- package/dist/cli/workspace-9rq1w4ta.js +0 -2
- package/dist/lib/index.js +0 -62
- package/dist/lib/models-index.json +0 -1
- package/dist/lib/plugins.js +0 -6
- package/dist/lib/providers.js +0 -17
- package/dist/lib/public-api.js +0 -20
- package/dist/rovecode.exe +0 -4
- /package/{dist/cli → src/providers}/models-index.json +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Plugins: activation. For every ACTIVE plugin, import its entry module (Bun runs TypeScript natively:
|
|
2
|
+
* `await import()`, no build step — the .rovecode/hooks.ts idiom, the same trust class), validate the
|
|
3
|
+
* default export, and resolve the declarative contributions (commands dir, skills dir, MCP servers).
|
|
4
|
+
* The result is a set of already-typed things the runtime drops onto existing seams:
|
|
5
|
+
* tools → ToolRegistry.register (the same policy path as built-ins: kind → action, deny-default)
|
|
6
|
+
* hooks → HookRunner.add(set, source) (timeout-bounded and isolated by the runner, like hook files)
|
|
7
|
+
* commandsDir / skillsDir → the commands and skills loaders' extra roots
|
|
8
|
+
* mcp → appended to loadMcpConfig's servers
|
|
9
|
+
* A module that fails, hangs or lies about its version costs ONE warning and that plugin's code
|
|
10
|
+
* contributions — never a throw, never the other plugins. Disabled/untrusted/broken plugins are
|
|
11
|
+
* returned with empty contributions so `plugin list` can still show them. */
|
|
12
|
+
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import { HOOK_NAMES, type HookSet } from "../core/hooks.ts";
|
|
17
|
+
import type { Tool, ToolContext, ToolKind, ToolOutput } from "../core/types.ts";
|
|
18
|
+
import { isRecord } from "../mcp/config.ts";
|
|
19
|
+
import type { DiscoveredPlugin } from "./discover.ts";
|
|
20
|
+
import { PLUGIN_API_VERSION } from "./manifest.ts";
|
|
21
|
+
|
|
22
|
+
/** what the entry module receives when `tools` is a function */
|
|
23
|
+
export interface PluginCtx { cwd: string; home: string; pluginDir: string }
|
|
24
|
+
|
|
25
|
+
/** the entry module's default export */
|
|
26
|
+
export interface PluginModule {
|
|
27
|
+
api: number;
|
|
28
|
+
tools?: Tool[] | ((ctx: PluginCtx) => Tool[] | Promise<Tool[]>);
|
|
29
|
+
hooks?: HookSet;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** a discovered plugin plus what its entry module contributed (empty unless status is active) */
|
|
33
|
+
export interface LoadedPlugin extends DiscoveredPlugin {
|
|
34
|
+
tools: Tool[];
|
|
35
|
+
hooks: HookSet | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ActivateOptions { cwd: string; home: string; /** import + tools() budget per plugin (default: ROVECODE_PLUGIN_TIMEOUT_MS, else 5000) */ timeoutMs?: number }
|
|
39
|
+
export const DEFAULT_PLUGIN_TIMEOUT_MS = 5000;
|
|
40
|
+
/** ROVECODE_PLUGIN_TIMEOUT_MS: blank/invalid/< 1 → default (the hooks.ts hookTimeoutMs rule) */
|
|
41
|
+
export function pluginTimeoutMs(env: Record<string, string | undefined> = process.env): number {
|
|
42
|
+
const n = Number(env.ROVECODE_PLUGIN_TIMEOUT_MS);
|
|
43
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_PLUGIN_TIMEOUT_MS;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const KINDS: readonly ToolKind[] = ["read", "write", "execute", "spawn", "memory", "network", "custom"];
|
|
47
|
+
const TOOL_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
48
|
+
|
|
49
|
+
/** kind → policy action, the same table core/tools.ts actionFor uses. `tool.<name>` has no
|
|
50
|
+
* declaration: a plugin's OWN name is its identity, not a capability. */
|
|
51
|
+
const KIND_ACTION: Partial<Record<ToolKind, string>> = {
|
|
52
|
+
read: "file.read", write: "file.write", execute: "shell.exec", spawn: "spawn", memory: "memory.write", network: "net.fetch",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Permission declaration (manifest.permissions, sdk-blueprint.md §6.5): a plugin that declares
|
|
56
|
+
* permissions gets each tool wrapped — a call whose kind-action the manifest did not declare is
|
|
57
|
+
* refused BEFORE execute, with a message naming the plugin and the missing declaration. The user
|
|
58
|
+
* reads the declaration at install time (plugin show / market info); this wrap makes the promise
|
|
59
|
+
* true at run time. No declaration = legacy unrestricted (no wrap). */
|
|
60
|
+
function wrapPermissions(tool: Tool, declared: string[] | undefined, tag: string): Tool {
|
|
61
|
+
if (declared === undefined) return tool;
|
|
62
|
+
const action = KIND_ACTION[tool.kind];
|
|
63
|
+
if (action === undefined) return tool; // custom `tool.<name>` — no capability to declare
|
|
64
|
+
if (declared.includes(action) || declared.includes("*")) return tool;
|
|
65
|
+
return {
|
|
66
|
+
...tool,
|
|
67
|
+
execute: async (_args: unknown, _ctx: ToolContext): Promise<ToolOutput> => ({
|
|
68
|
+
ok: false,
|
|
69
|
+
output: `Permission denied: plugin '${tag}' did not declare '${action}' — add it to the manifest's "permissions" and re-install, or remove this tool`,
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const TIMED_OUT = Symbol("timed-out");
|
|
74
|
+
|
|
75
|
+
async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
|
|
76
|
+
let t: ReturnType<typeof setTimeout> | undefined;
|
|
77
|
+
const timer = new Promise<typeof TIMED_OUT>((r) => { t = setTimeout(() => r(TIMED_OUT), ms); });
|
|
78
|
+
try { return await Promise.race([p, timer]); } finally { if (t !== undefined) clearTimeout(t); }
|
|
79
|
+
}
|
|
80
|
+
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
81
|
+
|
|
82
|
+
export async function activatePlugins(found: readonly DiscoveredPlugin[], opts: ActivateOptions): Promise<{ plugins: LoadedPlugin[]; warnings: string[] }> {
|
|
83
|
+
const warnings: string[] = [];
|
|
84
|
+
const plugins: LoadedPlugin[] = [];
|
|
85
|
+
for (const p of found) plugins.push(await activateOne(p, opts, warnings));
|
|
86
|
+
return { plugins, warnings };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function activateOne(p: DiscoveredPlugin, opts: ActivateOptions, warnings: string[]): Promise<LoadedPlugin> {
|
|
90
|
+
const out: LoadedPlugin = { ...p, tools: [], hooks: null };
|
|
91
|
+
if (p.status !== "active" || p.manifest === null) return out; // untrusted / disabled / broken: nothing of theirs runs
|
|
92
|
+
const m = p.manifest, tag = `plugin ${p.name}`;
|
|
93
|
+
if (!m.entry) return out;
|
|
94
|
+
const entry = join(p.dir, m.entry);
|
|
95
|
+
if (!existsSync(entry)) { warnings.push(`${tag}: entry ${m.entry} does not exist — no tools or hooks loaded`); return out; }
|
|
96
|
+
const ms = opts.timeoutMs ?? pluginTimeoutMs();
|
|
97
|
+
let mod: unknown;
|
|
98
|
+
try { mod = await withTimeout(import(pathToFileURL(entry).href), ms); }
|
|
99
|
+
catch (e) { warnings.push(`${tag}: ${m.entry} failed to load — ${errText(e)}`); return out; }
|
|
100
|
+
if (mod === TIMED_OUT) { warnings.push(`${tag}: ${m.entry} load timed out after ${ms}ms (top-level await?) — skipped`); return out; }
|
|
101
|
+
const dflt: unknown = isRecord(mod) ? mod["default"] : undefined;
|
|
102
|
+
if (!isRecord(dflt)) { warnings.push(`${tag}: ${m.entry} must \`export default { api: ${PLUGIN_API_VERSION}, tools?, hooks? }\` — skipped`); return out; }
|
|
103
|
+
if (dflt["api"] !== PLUGIN_API_VERSION) { warnings.push(`${tag}: ${m.entry} declares api ${String(dflt["api"])}; this rovecode speaks ${PLUGIN_API_VERSION} — skipped`); return out; }
|
|
104
|
+
for (const key of Object.keys(dflt)) if (!["api", "tools", "hooks"].includes(key)) warnings.push(`${tag}: ${m.entry} exports unknown member "${key}" — ignored`);
|
|
105
|
+
// tools: an array, or a factory that gets the plugin's ctx (its own folder for data files, the cwd, the home)
|
|
106
|
+
if (dflt["tools"] !== undefined) {
|
|
107
|
+
let list: unknown;
|
|
108
|
+
try {
|
|
109
|
+
const raw = dflt["tools"];
|
|
110
|
+
list = typeof raw === "function" ? await withTimeout(Promise.resolve((raw as (c: PluginCtx) => unknown)({ cwd: opts.cwd, home: opts.home, pluginDir: p.dir })), ms) : raw;
|
|
111
|
+
} catch (e) { warnings.push(`${tag}: tools() threw — ${errText(e)}`); list = []; }
|
|
112
|
+
if (list === TIMED_OUT) { warnings.push(`${tag}: tools() timed out after ${ms}ms — no tools loaded`); list = []; }
|
|
113
|
+
if (!Array.isArray(list)) { warnings.push(`${tag}: "tools" must be an array (or a function returning one) — ignored`); list = []; }
|
|
114
|
+
const seen = new Set<string>();
|
|
115
|
+
for (const t of list as unknown[]) {
|
|
116
|
+
const ok = validateTool(t, tag, warnings);
|
|
117
|
+
if (!ok) continue;
|
|
118
|
+
if (seen.has(ok.schema.name)) { warnings.push(`${tag}: tool "${ok.schema.name}" declared twice — first kept`); continue; }
|
|
119
|
+
seen.add(ok.schema.name); out.tools.push(wrapPermissions(ok, m.permissions, tag));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// hooks: the HookSet shape hooks.ts validates for files — unknown names and non-functions dropped, the rest kept
|
|
123
|
+
if (dflt["hooks"] !== undefined) {
|
|
124
|
+
if (!isRecord(dflt["hooks"])) warnings.push(`${tag}: "hooks" must be an object of hook functions — ignored`);
|
|
125
|
+
else {
|
|
126
|
+
const set: Record<string, unknown> = {};
|
|
127
|
+
for (const [name, fn] of Object.entries(dflt["hooks"])) {
|
|
128
|
+
if (!(HOOK_NAMES as readonly string[]).includes(name)) { warnings.push(`${tag}: unknown hook "${name}" ignored (known: ${HOOK_NAMES.join(", ")})`); continue; }
|
|
129
|
+
if (typeof fn !== "function") { warnings.push(`${tag}: hook "${name}" is not a function — ignored`); continue; }
|
|
130
|
+
set[name] = fn;
|
|
131
|
+
}
|
|
132
|
+
if (Object.keys(set).length) out.hooks = set as HookSet;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** a Tool as core/types.ts defines it; anything else is named and dropped */
|
|
139
|
+
function validateTool(t: unknown, tag: string, warnings: string[]): Tool | null {
|
|
140
|
+
if (!isRecord(t) || !isRecord(t["schema"])) { warnings.push(`${tag}: a tool without a schema was dropped`); return null; }
|
|
141
|
+
const s = t["schema"];
|
|
142
|
+
const name = s["name"];
|
|
143
|
+
if (typeof name !== "string" || !TOOL_NAME_RE.test(name)) { warnings.push(`${tag}: tool name ${JSON.stringify(name)} must match ${TOOL_NAME_RE} — dropped`); return null; }
|
|
144
|
+
if (typeof s["description"] !== "string" || !s["description"].trim()) { warnings.push(`${tag}: tool "${name}" needs a description — dropped`); return null; }
|
|
145
|
+
if (!isRecord(s["args"])) { warnings.push(`${tag}: tool "${name}" needs a JSON-Schema "args" object — dropped`); return null; }
|
|
146
|
+
if (!KINDS.includes(t["kind"] as ToolKind)) { warnings.push(`${tag}: tool "${name}" kind ${JSON.stringify(t["kind"])} is not one of ${KINDS.join("|")} — dropped (the kind is its permission class)`); return null; }
|
|
147
|
+
if (typeof t["execute"] !== "function") { warnings.push(`${tag}: tool "${name}" has no execute() — dropped`); return null; }
|
|
148
|
+
return t as unknown as Tool;
|
|
149
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Plugins: the manifest. A plugin is a FOLDER with a `plugin.json` that names what the folder
|
|
2
|
+
* contributes — an in-process entry module (tools + hooks), a directory of custom commands, a
|
|
3
|
+
* directory of skills, MCP servers. Nothing here is a new extension API: every contribution lands on
|
|
4
|
+
* a seam rovecode already has (core/hooks.ts HookSet, core/tools.ts ToolRegistry, tui/commands.ts,
|
|
5
|
+
* skills/index.ts, mcp/config.ts). The plugin is the package, not the surface (ADR-013: no 1.8k-line
|
|
6
|
+
* extension API — a small typed set, versioned; the manifest is versioned the same way hooks are).
|
|
7
|
+
*
|
|
8
|
+
* Pure: text in, manifest or null out, every rejection a warning that names the file and the field.
|
|
9
|
+
* Paths in the manifest are RELATIVE and may not climb out of the plugin folder — a manifest is data
|
|
10
|
+
* from a checkout, and "../../.rovecode/hooks.ts" is not a plugin's to name. */
|
|
11
|
+
|
|
12
|
+
import { isRecord } from "../mcp/config.ts";
|
|
13
|
+
|
|
14
|
+
export const PLUGIN_API_VERSION = 1;
|
|
15
|
+
export const MANIFEST_FILE = "plugin.json";
|
|
16
|
+
/** `rovecode plugin list` and the palette show this; a paragraph is not a description */
|
|
17
|
+
export const MAX_DESCRIPTION_CHARS = 200;
|
|
18
|
+
|
|
19
|
+
export interface PluginManifest {
|
|
20
|
+
/** identity: folder-safe, lowercase, unique per scope — `conventional-commits`, `safety-net` */
|
|
21
|
+
name: string;
|
|
22
|
+
version: string;
|
|
23
|
+
description: string;
|
|
24
|
+
/** manifest/API version gate; this rovecode speaks PLUGIN_API_VERSION */
|
|
25
|
+
api: number;
|
|
26
|
+
/** relative path of the in-process module (`export default { api: 1, tools, hooks }`); .ts or .js */
|
|
27
|
+
entry?: string;
|
|
28
|
+
/** relative directory of `*.md` custom commands (tui/commands.ts format) */
|
|
29
|
+
commands?: string;
|
|
30
|
+
/** relative directory walked for SKILL.md files (skills/index.ts format) */
|
|
31
|
+
skills?: string;
|
|
32
|
+
/** MCP servers keyed by name, `.rovecode/mcp.json` entry shape */
|
|
33
|
+
mcp?: Record<string, unknown>;
|
|
34
|
+
/** Permission declaration (sdk-blueprint.md §6.5): the policy actions this plugin's tools
|
|
35
|
+
* may take — file.read, file.write, shell.exec, spawn, memory.write, net.fetch. A plugin
|
|
36
|
+
* tool whose kind maps outside the declaration is refused at activation-time wrapping:
|
|
37
|
+
* the user reads this list BEFORE installing (plugin show / market info) and a quiet
|
|
38
|
+
* capability grab becomes a loud load-time refusal. Absent = legacy unrestricted. */
|
|
39
|
+
permissions?: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
43
|
+
const ENTRY_RE = /\.(?:[cm]?[jt]s)$/i;
|
|
44
|
+
|
|
45
|
+
/** a relative path that stays inside the plugin: no absolute, no drive, no `..` segment */
|
|
46
|
+
export function insidePlugin(p: string): boolean {
|
|
47
|
+
if (p.length === 0 || p.startsWith("/") || p.startsWith("\\") || /^[A-Za-z]:/.test(p)) return false;
|
|
48
|
+
return !p.split(/[\\/]/).some((seg) => seg === "..");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `plugin.json` text → manifest, or null with the reasons appended to `warnings`. A wrong or missing
|
|
52
|
+
* `api` is a hard skip (the version gate); everything else that is off is dropped field by field so
|
|
53
|
+
* a typo in `skills` does not lose the plugin's tools. */
|
|
54
|
+
export function parseManifest(text: string, file: string, warnings: string[]): PluginManifest | null {
|
|
55
|
+
let raw: unknown;
|
|
56
|
+
try { raw = JSON.parse(text); } catch (e) { warnings.push(`${file}: not valid JSON — ${(e as Error).message}`); return null; }
|
|
57
|
+
if (!isRecord(raw)) { warnings.push(`${file}: must be an object`); return null; }
|
|
58
|
+
const api = raw["api"];
|
|
59
|
+
if (api !== PLUGIN_API_VERSION) {
|
|
60
|
+
const shown = api === undefined ? "missing" : typeof api === "string" ? JSON.stringify(api) : String(api);
|
|
61
|
+
warnings.push(`${file}: plugin API version ${shown} is not supported (this rovecode speaks ${PLUGIN_API_VERSION}) — skipped`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const name = raw["name"];
|
|
65
|
+
if (typeof name !== "string" || !NAME_RE.test(name)) { warnings.push(`${file}: "name" must match ${NAME_RE} — skipped`); return null; }
|
|
66
|
+
const version = typeof raw["version"] === "string" && raw["version"].trim() ? raw["version"].trim().slice(0, 40) : null;
|
|
67
|
+
if (version === null) { warnings.push(`${file}: "version" must be a non-empty string — skipped`); return null; }
|
|
68
|
+
const m: PluginManifest = { name, version, description: "", api: PLUGIN_API_VERSION };
|
|
69
|
+
if (raw["description"] !== undefined) {
|
|
70
|
+
if (typeof raw["description"] === "string") m.description = raw["description"].replace(/\s+/g, " ").trim().slice(0, MAX_DESCRIPTION_CHARS);
|
|
71
|
+
else warnings.push(`${file}: "description" is not a string — ignored`);
|
|
72
|
+
}
|
|
73
|
+
for (const key of ["entry", "commands", "skills"] as const) {
|
|
74
|
+
const v = raw[key];
|
|
75
|
+
if (v === undefined) continue;
|
|
76
|
+
if (typeof v !== "string" || !insidePlugin(v)) { warnings.push(`${file}: "${key}" must be a relative path inside the plugin — ignored`); continue; }
|
|
77
|
+
if (key === "entry" && !ENTRY_RE.test(v)) { warnings.push(`${file}: "entry" must be a .ts or .js module — ignored`); continue; }
|
|
78
|
+
m[key] = v.replace(/\\/g, "/");
|
|
79
|
+
}
|
|
80
|
+
if (raw["mcp"] !== undefined) {
|
|
81
|
+
if (isRecord(raw["mcp"])) m.mcp = raw["mcp"];
|
|
82
|
+
else warnings.push(`${file}: "mcp" must be an object of servers keyed by name — ignored`);
|
|
83
|
+
}
|
|
84
|
+
if (raw["permissions"] !== undefined) {
|
|
85
|
+
const p = raw["permissions"];
|
|
86
|
+
if (Array.isArray(p) && p.every((x) => typeof x === "string" && /^[a-z][a-z0-9.*-]{0,63}$/.test(x))) {
|
|
87
|
+
m.permissions = p.slice(0, 16);
|
|
88
|
+
if (p.length > 16) warnings.push(`${file}: "permissions" capped at 16 entries`);
|
|
89
|
+
} else warnings.push(`${file}: "permissions" must be an array of action names (e.g. "shell.exec") — ignored`);
|
|
90
|
+
}
|
|
91
|
+
for (const key of Object.keys(raw)) {
|
|
92
|
+
if (!["name", "version", "description", "api", "entry", "commands", "skills", "mcp", "permissions"].includes(key)) warnings.push(`${file}: unknown field "${key}" ignored`);
|
|
93
|
+
}
|
|
94
|
+
return m;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** what a manifest says it brings, for `plugin list` / `plugin show` / the trust prompt */
|
|
98
|
+
export function contributions(m: PluginManifest): string[] {
|
|
99
|
+
const out: string[] = [];
|
|
100
|
+
if (m.entry) out.push(`code: ${m.entry}`);
|
|
101
|
+
if (m.commands) out.push(`commands: ${m.commands}/`);
|
|
102
|
+
if (m.skills) out.push(`skills: ${m.skills}/`);
|
|
103
|
+
if (m.mcp) out.push(`mcp: ${Object.keys(m.mcp).join(", ") || "(none)"}`);
|
|
104
|
+
if (m.permissions) out.push(`permissions: ${m.permissions.length > 0 ? m.permissions.join(", ") : "(none — read-only)"}`);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Plugins: what the human decided about them — `~/.rovecode/plugins.json`. Two facts only:
|
|
2
|
+
* which plugins are switched off, and which PROJECT plugins this machine trusts, by folder and by a
|
|
3
|
+
* digest of the folder's contents.
|
|
4
|
+
*
|
|
5
|
+
* Why a digest and why in the USER home: a project plugin lives in a checkout, and a checkout is
|
|
6
|
+
* data from the network. Recording "trusted: true" inside `<cwd>/.rovecode/settings.json` would let the
|
|
7
|
+
* repository trust itself; recording it here, keyed by the plugin's absolute folder and the hash of
|
|
8
|
+
* every file in it, means the human said yes to THIS code on THIS machine, and a `git pull` that
|
|
9
|
+
* changes the plugin asks again. User-scope plugins need no record: `rovecode plugin add` copying a
|
|
10
|
+
* folder into `~/.rovecode/plugins` IS the human's yes.
|
|
11
|
+
*
|
|
12
|
+
* A missing, unreadable or malformed file reads as "nothing decided" — never an error. */
|
|
13
|
+
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
export interface PluginState {
|
|
19
|
+
/** plugin names switched off in every scope */
|
|
20
|
+
disabled: string[];
|
|
21
|
+
/** absolute project-plugin folder → digest the human approved */
|
|
22
|
+
trusted: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const STATE_FILE = "plugins.json";
|
|
26
|
+
export const statePath = (home: string): string => join(home, STATE_FILE);
|
|
27
|
+
|
|
28
|
+
const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
29
|
+
|
|
30
|
+
export function loadState(home: string): PluginState {
|
|
31
|
+
const out: PluginState = { disabled: [], trusted: {} };
|
|
32
|
+
let raw: unknown;
|
|
33
|
+
try { raw = JSON.parse(readFileSync(statePath(home), "utf8")); } catch { return out; }
|
|
34
|
+
if (typeof raw !== "object" || raw === null) return out;
|
|
35
|
+
const r = raw as Record<string, unknown>;
|
|
36
|
+
if (Array.isArray(r["disabled"])) out.disabled = r["disabled"].filter((v): v is string => typeof v === "string");
|
|
37
|
+
if (typeof r["trusted"] === "object" && r["trusted"] !== null && !Array.isArray(r["trusted"])) {
|
|
38
|
+
for (const [k, v] of Object.entries(r["trusted"] as Record<string, unknown>)) if (typeof v === "string") out.trusted[k] = v;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function saveState(home: string, state: PluginState): string {
|
|
44
|
+
const path = statePath(home);
|
|
45
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
46
|
+
writeFileSync(path, JSON.stringify({ disabled: [...state.disabled].sort(), trusted: state.trusted }, null, 2) + "\n");
|
|
47
|
+
return path;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** every regular file under the plugin folder (node_modules/.git skipped), sorted, relative posix paths */
|
|
51
|
+
export function pluginFiles(dir: string): string[] {
|
|
52
|
+
const out: string[] = [];
|
|
53
|
+
const walk = (d: string): void => {
|
|
54
|
+
let entries: import("node:fs").Dirent[];
|
|
55
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
58
|
+
const p = join(d, e.name);
|
|
59
|
+
if (e.isDirectory()) walk(p);
|
|
60
|
+
else if (e.isFile()) out.push(relative(dir, p).replace(/\\/g, "/"));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
walk(dir);
|
|
64
|
+
return out.sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** sha256 over `<relative path>\0<bytes>\0` for every file — the same folder gives the same digest on
|
|
68
|
+
* every machine, and one changed byte in one file changes it */
|
|
69
|
+
export function pluginDigest(dir: string): string {
|
|
70
|
+
const h = createHash("sha256");
|
|
71
|
+
for (const rel of pluginFiles(dir)) {
|
|
72
|
+
h.update(rel); h.update("\0");
|
|
73
|
+
try { h.update(readFileSync(join(dir, rel))); } catch { /* vanished mid-walk: the digest still moves */ }
|
|
74
|
+
h.update("\0");
|
|
75
|
+
}
|
|
76
|
+
return h.digest("hex");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const trustKey = (dir: string): string => resolve(dir).replace(/\\/g, "/");
|
|
80
|
+
export function isTrusted(state: PluginState, dir: string, digest: string): boolean { return state.trusted[trustKey(dir)] === digest; }
|
|
81
|
+
|
|
82
|
+
/** a folder exists and is a directory */
|
|
83
|
+
export function isDir(p: string): boolean { try { return statSync(p).isDirectory(); } catch { return false; } }
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/** Provider credential store (port #37): backing for `rovecode auth set/list/remove`.
|
|
2
|
+
*
|
|
3
|
+
* Ported from opencode packages/opencode/src/auth/index.ts @ ebece6e (MIT):
|
|
4
|
+
* - one JSON object keyed by provider id, entries discriminated by `type` (index.ts:14-36)
|
|
5
|
+
* - the "api" variant carries the secret in `key` (index.ts:23-27); the provider loader
|
|
6
|
+
* consumes it as the apiKey (provider.ts:1596-1601)
|
|
7
|
+
* - file written with mode 0o600 (index.ts:79,88)
|
|
8
|
+
* - a missing/corrupt file reads as {} and malformed entries are dropped per-entry, never
|
|
9
|
+
* fatal (index.ts:65-66, Record.filterMap over the schema decode)
|
|
10
|
+
* Deviations: the file lives at ~/.rovecode/credentials.json (bar) instead of opencode's
|
|
11
|
+
* <data>/auth.json; entries carry an rovecode-only optional `keyName` (which env var the
|
|
12
|
+
* secret stands in for); only the "api" variant is implemented — entries with other
|
|
13
|
+
* `type` values (a future oauth port) round-trip through save/remove unharmed but are
|
|
14
|
+
* not listed or resolved.
|
|
15
|
+
*
|
|
16
|
+
* Also home to readSecret (the `rovecode auth set` prompt) so its TTY path is unit-testable
|
|
17
|
+
* in-process — main.ts cannot be imported by tests (it dispatches on load).
|
|
18
|
+
*
|
|
19
|
+
* SECRETS ARE NEVER LOGGED from this module: the only terminal output is readSecret's
|
|
20
|
+
* prompt text plus cursor-control sequences, error messages never embed the credential
|
|
21
|
+
* value, and the store itself prints nothing. Rendering (redacted) is the caller's job
|
|
22
|
+
* via listProviders()/redactSecret().
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync, cpSync, existsSync, rmSync } from "node:fs";
|
|
26
|
+
import { createInterface } from "node:readline";
|
|
27
|
+
import { Writable } from "node:stream";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { createRequire } from "node:module";
|
|
31
|
+
|
|
32
|
+
/** The provider index, loaded ONLY when something asks for a key name.
|
|
33
|
+
*
|
|
34
|
+
* Two things were wrong here and they compounded. The import was at the top of the module, and what it
|
|
35
|
+
* imported was the full models.dev snapshot: 63 MB resident, for one call site (`keyNameFor`). Meanwhile
|
|
36
|
+
* `rovecodeHome`, three lines of path arithmetic in this same file, is imported by hooks.ts, settings.ts,
|
|
37
|
+
* runtime.ts, the plugin loader and half the TUI — so all of them paid 63 MB to learn where `~/.rovecode`
|
|
38
|
+
* is, and the TUI paid it at startup before drawing a frame.
|
|
39
|
+
*
|
|
40
|
+
* Now it is deferred, and it reads the trimmed index (src/providers/models-index.json, the same file the
|
|
41
|
+
* catalog reads) rather than the upstream snapshot. Only `env[0]` is ever used from it.
|
|
42
|
+
*
|
|
43
|
+
* `require` rather than `await import` because `keyNameFor` is synchronous and called from synchronous
|
|
44
|
+
* code; making it async would push the change through a dozen call sites to save nothing extra. */
|
|
45
|
+
let snapshotCache: Record<string, { env?: string[] } | undefined> | null = null;
|
|
46
|
+
function snapshotProvidersLazy(): Record<string, { env?: string[] } | undefined> {
|
|
47
|
+
if (snapshotCache === null) {
|
|
48
|
+
const req = createRequire(import.meta.url);
|
|
49
|
+
const m = req("./models-index.json") as { providers?: Record<string, { env?: string[] }> };
|
|
50
|
+
snapshotCache = m.providers ?? {};
|
|
51
|
+
}
|
|
52
|
+
return snapshotCache;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface StoredCredential {
|
|
56
|
+
type: "api";
|
|
57
|
+
/** the secret itself (upstream field name — auth/index.ts:24) */
|
|
58
|
+
key: string;
|
|
59
|
+
/** rovecode extension: env var name this secret stands in for (e.g. ANTHROPIC_API_KEY) */
|
|
60
|
+
keyName?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** User-scope rovecode dir: ROVECODE_HOME overrides ~/.rovecode wholesale (tests point it at a temp
|
|
64
|
+
* dir). homedir() already respects HOME on POSIX and USERPROFILE on Windows. Mirrors the
|
|
65
|
+
* skills store's user-scope default (skills/index.ts: join(homedir(), ".rovecode", ...)). */
|
|
66
|
+
/** what node's homedir() promises, spelled out so every runtime agrees on every host: HOME on POSIX,
|
|
67
|
+
* USERPROFILE on Windows, the OS account's directory only when neither is set (tests point HOME at a
|
|
68
|
+
* scratch dir and expect the home to follow) */
|
|
69
|
+
const userHome = (): string => (process.platform === "win32" ? process.env.USERPROFILE : process.env.HOME) || homedir();
|
|
70
|
+
|
|
71
|
+
export function rovecodeHome(): string {
|
|
72
|
+
const explicit = process.env.ROVECODE_HOME;
|
|
73
|
+
const home = explicit ?? join(userHome(), ".rovecode");
|
|
74
|
+
migrateLegacyHome(home, { explicit: explicit !== undefined });
|
|
75
|
+
return home;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The config directory used to be `~/.cumulus` (the project was called nimbus). A rename must not
|
|
79
|
+
* cost anyone their stored API keys and providers, so the first call that resolves the new home
|
|
80
|
+
* COPIES the old one into it — copy, not move: the old directory is left exactly as it was, so
|
|
81
|
+
* downgrading to an older build keeps working and nothing is destroyed if this goes wrong.
|
|
82
|
+
*
|
|
83
|
+
* Runs once per process, only when the new home does not exist yet and the old one does, and ONLY for
|
|
84
|
+
* the default home. An explicit ROVECODE_HOME used to be migrated the same way "which is what makes it
|
|
85
|
+
* testable", and that was a real hazard rather than a convenience: pointing ROVECODE_HOME at a fresh
|
|
86
|
+
* path — the ordinary way to get an isolated home for a test, a script or a clean-room check — silently
|
|
87
|
+
* filled it with a copy of the old credentials. It billed two real API calls during this repository's
|
|
88
|
+
* own release verification (2026-09-06) before anyone noticed the scratch home was not scratch. Tests
|
|
89
|
+
* that need to exercise the migration pass `legacyDir`; nothing else copies a user's keys into a path
|
|
90
|
+
* they chose for isolation.
|
|
91
|
+
*
|
|
92
|
+
* Any failure is swallowed: a migration that cannot run must not stop the agent from starting — the
|
|
93
|
+
* user simply sees "no provider configured" and runs `rovecode connect`. */
|
|
94
|
+
let migrated = false;
|
|
95
|
+
export function migrateLegacyHome(home: string, opts: { explicit?: boolean; legacyDir?: string; note?: (line: string) => void } = {}): void {
|
|
96
|
+
if (migrated && opts.legacyDir === undefined) return;
|
|
97
|
+
if (opts.legacyDir === undefined) migrated = true;
|
|
98
|
+
try {
|
|
99
|
+
if (existsSync(home)) return; // already living in the new place
|
|
100
|
+
const legacy = opts.legacyDir ?? join(userHome(), ".cumulus");
|
|
101
|
+
// an explicit ROVECODE_HOME is a request for THIS directory, not for a copy of another one. The
|
|
102
|
+
// injected legacyDir does not override it: a test that wants the explicit case must SEE the refusal.
|
|
103
|
+
if (opts.explicit === true) return;
|
|
104
|
+
if (home === legacy || !existsSync(legacy)) return;
|
|
105
|
+
cpSync(legacy, home, { recursive: true });
|
|
106
|
+
// and it says so: a copy of someone's credentials appearing in a new directory is not a silent event
|
|
107
|
+
(opts.note ?? ((l: string) => console.error(l)))(`migrated ${legacy} → ${home} (the config directory was renamed; the old one is untouched)`);
|
|
108
|
+
// the credentials file carries the 0600 the old one had only on POSIX; re-assert it here
|
|
109
|
+
const creds = join(home, "credentials.json");
|
|
110
|
+
if (existsSync(creds) && process.platform !== "win32") chmodSync(creds, 0o600);
|
|
111
|
+
} catch { /* best effort: a failed migration is a fresh config, never a crash */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function credentialsPath(): string {
|
|
115
|
+
return join(rovecodeHome(), "credentials.json");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Raw file contents: every entry as stored, including unknown `type`s. Missing file,
|
|
119
|
+
* unreadable file, or non-object JSON -> {} (upstream orElseSucceed idiom). */
|
|
120
|
+
function readRaw(): Record<string, unknown> {
|
|
121
|
+
let parsed: unknown;
|
|
122
|
+
try {
|
|
123
|
+
parsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
|
|
124
|
+
} catch {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
128
|
+
return parsed as Record<string, unknown>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isApiCredential(value: unknown): value is StoredCredential {
|
|
132
|
+
if (typeof value !== "object" || value === null) return false;
|
|
133
|
+
const v = value as { type?: unknown; key?: unknown; keyName?: unknown };
|
|
134
|
+
// whitespace-only counts as empty: a hand-edited `"key": " "` must not shadow a valid env key
|
|
135
|
+
if (v.type !== "api" || typeof v.key !== "string" || v.key.trim().length === 0) return false;
|
|
136
|
+
return v.keyName === undefined || typeof v.keyName === "string";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Valid "api" credentials only, keyed by provider id (malformed entries dropped). */
|
|
140
|
+
export function loadCredentials(): Record<string, StoredCredential> {
|
|
141
|
+
const out: Record<string, StoredCredential> = {};
|
|
142
|
+
for (const [id, value] of Object.entries(readRaw())) {
|
|
143
|
+
if (isApiCredential(value)) out[id] = value;
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Write the store with restrictive permissions.
|
|
149
|
+
*
|
|
150
|
+
* Windows honesty note: fs mode bits on win32 map only onto the FILE_ATTRIBUTE_READONLY
|
|
151
|
+
* flag — 0o600 does NOT create owner-only protection there. Real isolation on Windows
|
|
152
|
+
* comes from the NTFS ACL on %USERPROFILE% (inherited by ~/.rovecode), which by default
|
|
153
|
+
* denies other non-admin users. So this is best-effort hardening on POSIX (where the
|
|
154
|
+
* 0o600/0o700 bits are enforced) and effectively a no-op on Windows beyond the profile
|
|
155
|
+
* ACL it inherits — we do not claim otherwise. */
|
|
156
|
+
function writeStore(data: Record<string, unknown>): void {
|
|
157
|
+
const path = credentialsPath();
|
|
158
|
+
mkdirSync(rovecodeHome(), { recursive: true, mode: 0o700 });
|
|
159
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
160
|
+
try {
|
|
161
|
+
chmodSync(path, 0o600); // `mode` above only applies on create; re-assert on rewrites
|
|
162
|
+
} catch {
|
|
163
|
+
// best-effort (see Windows note) — a failed chmod must not lose the write
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Store/replace the credential for a provider. `secret` is never echoed or thrown. */
|
|
168
|
+
export function saveCredential(provider: string, secret: string, keyName?: string): void {
|
|
169
|
+
const id = provider.trim();
|
|
170
|
+
if (id.length === 0) throw new Error("provider id must not be empty");
|
|
171
|
+
if (secret.trim().length === 0) throw new Error(`refusing to store an empty secret for ${id}`);
|
|
172
|
+
const data = readRaw();
|
|
173
|
+
const entry: StoredCredential = { type: "api", key: secret, keyName: keyName ?? keyNameFor(id) };
|
|
174
|
+
data[id] = entry;
|
|
175
|
+
writeStore(data);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Remove a provider's entry. Returns false (and leaves the file alone) when absent.
|
|
179
|
+
* Removing the last entry deletes the file rather than leaving an empty {} around. */
|
|
180
|
+
export function removeCredential(provider: string): boolean {
|
|
181
|
+
const data = readRaw();
|
|
182
|
+
if (!(provider in data)) return false;
|
|
183
|
+
delete data[provider];
|
|
184
|
+
if (Object.keys(data).length === 0) {
|
|
185
|
+
try {
|
|
186
|
+
rmSync(credentialsPath());
|
|
187
|
+
} catch {
|
|
188
|
+
writeStore(data);
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
writeStore(data);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** First 4 chars + "…" (bar wording). Secrets of 8 chars or fewer collapse to "…" alone —
|
|
197
|
+
* half of a short key is most of the key, so nothing of it is shown. */
|
|
198
|
+
export function redactSecret(secret: string): string {
|
|
199
|
+
return secret.length > 8 ? secret.slice(0, 4) + "…" : "…";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Redacted listing for `rovecode auth list`: provider + key NAME + redacted prefix.
|
|
203
|
+
* The full secret value never appears in the returned records. */
|
|
204
|
+
export function listProviders(): { provider: string; keyName: string; redacted: string }[] {
|
|
205
|
+
return Object.entries(loadCredentials())
|
|
206
|
+
.map(([provider, cred]) => ({
|
|
207
|
+
provider,
|
|
208
|
+
keyName: cred.keyName ?? keyNameFor(provider),
|
|
209
|
+
redacted: redactSecret(cred.key),
|
|
210
|
+
}))
|
|
211
|
+
.sort((a, b) => a.provider.localeCompare(b.provider));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** rovecode provider id -> models.dev provider key, mirroring catalog.ts PROVIDER_MAP for the
|
|
215
|
+
* non-identity ids (together -> "togetherai", fireworks -> "fireworks-ai") plus an
|
|
216
|
+
* auth-only alias: moonshot -> "moonshotai" (models.dev has no bare "moonshot" key — the
|
|
217
|
+
* catalog reaches it via VENDOR_PREFIX_MAP on model ids instead, which auth cannot use).
|
|
218
|
+
* Identity ids (anthropic/openai/deepseek/openrouter/...) need no entry: keyNameFor tries
|
|
219
|
+
* the id itself against the snapshot first. */
|
|
220
|
+
const AUTH_PROVIDER_MAP: Record<string, string> = {
|
|
221
|
+
together: "togetherai",
|
|
222
|
+
fireworks: "fireworks-ai",
|
|
223
|
+
moonshot: "moonshotai",
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/** Which env var / key name a provider expects. models.dev drives this (snapshot
|
|
227
|
+
* Provider.env, e.g. anthropic -> ANTHROPIC_API_KEY — same source opencode's provider
|
|
228
|
+
* loader reads at provider.ts:1583 @ ebece6e); providers absent from models.dev (kaesra,
|
|
229
|
+
* ollama, moondream, vllm) fall back to <ID>_API_KEY, which matches every envKey in
|
|
230
|
+
* stream.ts builtinProviders by construction. */
|
|
231
|
+
export function keyNameFor(providerId: string): string {
|
|
232
|
+
const key = AUTH_PROVIDER_MAP[providerId] ?? providerId;
|
|
233
|
+
const env = snapshotProvidersLazy()[key]?.env;
|
|
234
|
+
if (env !== undefined && env.length > 0 && env[0]) return env[0];
|
|
235
|
+
return providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_") + "_API_KEY";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------- secret prompt (`rovecode auth set`) ----------
|
|
239
|
+
|
|
240
|
+
type SecretInput = NodeJS.ReadableStream & { isTTY?: boolean; isRaw?: boolean };
|
|
241
|
+
type SecretOutput = NodeJS.WritableStream & { columns?: number };
|
|
242
|
+
/** Streams readSecret talks to — injectable so tests can drive a fake TTY in-process. */
|
|
243
|
+
export interface SecretPromptIO { input?: SecretInput; output?: SecretOutput }
|
|
244
|
+
|
|
245
|
+
/** Cursor-control sequence for the cooked-mode fallback: after Enter the terminal has echoed
|
|
246
|
+
* prompt+line and moved to the next row, so erase ONE row per wrapped row the echo occupied
|
|
247
|
+
* (cursor-up + erase-line each), then "\r". A single row is not enough — a 33-col prompt plus
|
|
248
|
+
* a 108-char Anthropic key wraps on any terminal narrower than 141 columns. Pure: pinned by
|
|
249
|
+
* the headless-xterm test at 80 and 120 columns. Unknown/zero width assumes 80. */
|
|
250
|
+
export function echoScrubSequence(promptLen: number, lineLen: number, columns: number): string {
|
|
251
|
+
const cols = columns > 0 ? Math.floor(columns) : 80;
|
|
252
|
+
const rows = Math.max(1, Math.ceil((promptLen + lineLen) / cols));
|
|
253
|
+
return "\x1b[1A\x1b[2K".repeat(rows) + "\r";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Read one secret line for `rovecode auth set`. What is guaranteed:
|
|
257
|
+
* - TTY stdin: the prompt goes to stderr and readline runs in terminal mode, which calls
|
|
258
|
+
* setRawMode(true): the terminal driver's echo is OFF and readline's own echo goes to a
|
|
259
|
+
* sink, so the keystrokes are never written to the terminal at all. Verified on a real
|
|
260
|
+
* Windows console under Bun 1.3.14 — the console input mode drops ENABLE_ECHO_INPUT and
|
|
261
|
+
* ENABLE_LINE_INPUT, the screen buffer stays clean, and the mode is restored on close.
|
|
262
|
+
* - Fallback, only if raw mode did not take (no/failing setRawMode, input.isRaw stays false):
|
|
263
|
+
* the driver echoed, so after Enter every row the prompt+echo wrapped onto is erased
|
|
264
|
+
* (echoScrubSequence). A terminal that ignores VT cursor sequences keeps that echo.
|
|
265
|
+
* - Piped stdin (scripts, tests): one line, no prompt, nothing written to any stream.
|
|
266
|
+
* The value is returned trimmed and is never logged or embedded in an error. */
|
|
267
|
+
export function readSecret(promptText: string, io: SecretPromptIO = {}): Promise<string> {
|
|
268
|
+
const input = io.input ?? process.stdin;
|
|
269
|
+
const output = io.output ?? process.stderr;
|
|
270
|
+
const tty = input.isTTY === true;
|
|
271
|
+
if (tty) output.write(promptText);
|
|
272
|
+
const rl = tty
|
|
273
|
+
? createInterface({ input, output: new Writable({ write: (_c, _e, cb) => cb() }), terminal: true })
|
|
274
|
+
: createInterface({ input });
|
|
275
|
+
const raw = tty && input.isRaw === true;
|
|
276
|
+
return new Promise((resolve) => {
|
|
277
|
+
let settled = false; // rl.close() emits "close" SYNCHRONOUSLY — guard the race
|
|
278
|
+
rl.once("line", (line) => {
|
|
279
|
+
if (settled) return;
|
|
280
|
+
settled = true;
|
|
281
|
+
// raw: nothing was echoed, just end the prompt line; not raw: erase the echoed rows
|
|
282
|
+
if (tty) output.write(raw ? "\n" : echoScrubSequence(promptText.length, line.length, output.columns ?? 80));
|
|
283
|
+
rl.close();
|
|
284
|
+
resolve(line.trim());
|
|
285
|
+
});
|
|
286
|
+
rl.once("close", () => { // EOF / Ctrl+C / Ctrl+D without a line
|
|
287
|
+
if (settled) return;
|
|
288
|
+
settled = true;
|
|
289
|
+
if (tty) output.write("\n");
|
|
290
|
+
resolve("");
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
}
|