google-labs-jules[bot] commited on
Commit
93d826e
·
0 Parent(s):

Final deployment for HF with landing page

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ dist-server/
2
+ cli/
3
+ node_modules/
4
+ plandex-server
5
+ plandex-cloud
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.sh text eol=lf
.github/workflows/docker-publish.yml ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Build and publish Docker Image
2
+
3
+ on:
4
+ release:
5
+ types: [created]
6
+ workflow_dispatch: # enable manual triggering
7
+
8
+ jobs:
9
+ check_release:
10
+ runs-on: ubuntu-latest
11
+ outputs:
12
+ should_build: ${{ steps.check_release.outputs.should_build }}
13
+ tag: ${{ steps.check_release.outputs.tag }}
14
+
15
+ steps:
16
+ - name: Check release tag and find latest server tag
17
+ id: check_release
18
+ run: |
19
+ if [ "${{ github.event_name }}" == "release" ]; then
20
+ # This is a release event - check if the tag starts with 'server'
21
+ if [[ "${{ github.ref_name }}" == server* ]]; then
22
+ echo "This is a server release: ${{ github.ref_name }}"
23
+ echo "should_build=true" >> $GITHUB_OUTPUT
24
+ echo "tag=${{ github.ref_name }}" >> $GITHUB_OUTPUT
25
+ else
26
+ echo "This is not a server release. Skipping build."
27
+ echo "should_build=false" >> $GITHUB_OUTPUT
28
+ fi
29
+ else
30
+ # This is a manual workflow_dispatch event
31
+ echo "This is a manual workflow trigger. Proceeding to find latest server tag."
32
+ echo "should_build=true" >> $GITHUB_OUTPUT
33
+ echo "tag=latest_server" >> $GITHUB_OUTPUT
34
+ fi
35
+
36
+ build_and_push:
37
+ needs: check_release
38
+ if: needs.check_release.outputs.should_build == 'true'
39
+ runs-on: ubuntu-latest
40
+
41
+ steps:
42
+ - name: Check out the repo
43
+ uses: actions/checkout@v2
44
+ with:
45
+ fetch-depth: 0 # Fetch all history and tags
46
+
47
+ - name: Find latest server tag
48
+ id: find_tag
49
+ if: needs.check_release.outputs.tag == 'latest_server'
50
+ run: |
51
+ # Find the latest tag that starts with 'server'
52
+ LATEST_SERVER_TAG=$(git tag -l "server*" --sort=-creatordate | head -n 1)
53
+
54
+ if [ -z "$LATEST_SERVER_TAG" ]; then
55
+ echo "No tags starting with 'server' found."
56
+ echo "skip=true" >> $GITHUB_OUTPUT
57
+ else
58
+ echo "Found latest server tag: $LATEST_SERVER_TAG"
59
+ echo "skip=false" >> $GITHUB_OUTPUT
60
+ echo "tag=$LATEST_SERVER_TAG" >> $GITHUB_OUTPUT
61
+ fi
62
+ shell: bash
63
+
64
+ - name: Set release tag
65
+ id: set_tag
66
+ if: needs.check_release.outputs.tag != 'latest_server'
67
+ run: |
68
+ echo "skip=false" >> $GITHUB_OUTPUT
69
+ echo "tag=${{ needs.check_release.outputs.tag }}" >> $GITHUB_OUTPUT
70
+
71
+ - name: Skip build if no server tag found
72
+ if: (steps.find_tag.outputs.skip == 'true' && needs.check_release.outputs.tag == 'latest_server')
73
+ run: |
74
+ echo "Skipping build because no tag starting with 'server' was found."
75
+ exit 1
76
+
77
+ - name: Set up QEMU
78
+ uses: docker/setup-qemu-action@v2
79
+
80
+ - name: Set up Docker Buildx
81
+ uses: docker/setup-buildx-action@v1
82
+
83
+ - name: Log in to Docker Hub
84
+ uses: docker/login-action@v1
85
+ with:
86
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
87
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
88
+
89
+ - name: Determine tag to use
90
+ id: determine_tag
91
+ run: |
92
+ if [ "${{ needs.check_release.outputs.tag }}" == "latest_server" ]; then
93
+ TAG="${{ steps.find_tag.outputs.tag }}"
94
+ else
95
+ TAG="${{ needs.check_release.outputs.tag }}"
96
+ fi
97
+ echo "Using tag: $TAG"
98
+ echo "tag=$TAG" >> $GITHUB_OUTPUT
99
+
100
+ - name: Sanitize tag name
101
+ id: sanitize
102
+ run: echo "SANITIZED_TAG_NAME=$(echo ${{ steps.determine_tag.outputs.tag }} | tr '/' '-' | tr '+' '-')" >> $GITHUB_OUTPUT
103
+
104
+ - name: Build and push
105
+ uses: docker/build-push-action@v2
106
+ with:
107
+ context: ./app/
108
+ file: ./app/server/Dockerfile
109
+ push: true
110
+ platforms: linux/amd64,linux/arm64
111
+ tags: |
112
+ plandexai/plandex-server:${{ steps.sanitize.outputs.SANITIZED_TAG_NAME }}
113
+ plandexai/plandex-server:latest
.gitignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .plandex/
2
+ .plandex-dev/
3
+ .plandex-v2/
4
+ .plandex-dev-v2/
5
+ .envkey
6
+ .env
7
+ .env.*
8
+ plandex
9
+ plandex-dev
10
+ plandex-server
11
+ *.exe
12
+ node_modules/
13
+ /tools/
14
+ /static/
15
+ /infra/
16
+ /payments-dashboard/
17
+ .DS_Store
18
+ .goreleaser.yml
19
+ dist/
20
+ __pycache__/
21
+
22
+ .aider.*
23
+ *.code-workspace
24
+
25
+ __pycache__/
26
+
27
+ .repo_ignore
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 PlandexAI Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <h1 align="center">
2
+ <a href="https://plandex.ai">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="images/plandex-logo-dark-v2.png"/>
5
+ <source media="(prefers-color-scheme: light)" srcset="images/plandex-logo-light-v2.png"/>
6
+ <img width="400" src="images/plandex-logo-dark-bg-v2.png"/>
7
+ </a>
8
+ <br />
9
+ </h1>
10
+ <br />
11
+
12
+ <div align="center">
13
+
14
+ <p align="center">
15
+ <!-- Call to Action Links -->
16
+ <a href="#install">
17
+ <b>30-Second Install</b>
18
+ </a>
19
+ ·
20
+ <a href="https://plandex.ai">
21
+ <b>Website</b>
22
+ </a>
23
+ ·
24
+ <a href="https://docs.plandex.ai/">
25
+ <b>Docs</b>
26
+ </a>
27
+ ·
28
+ <a href="#examples-">
29
+ <b>Examples</b>
30
+ </a>
31
+ ·
32
+ <a href="https://docs.plandex.ai/hosting/self-hosting/local-mode-quickstart">
33
+ <b>Local Self-Hosted Mode</b>
34
+ </a>
35
+ </p>
36
+
37
+ <br>
38
+
39
+ [![Discord](https://img.shields.io/discord/1214825831973785600.svg?style=flat&logo=discord&label=Discord&refresh=1)](https://discord.gg/plandex-ai)
40
+ [![GitHub Repo stars](https://img.shields.io/github/stars/plandex-ai/plandex?style=social)](https://github.com/plandex-ai/plandex)
41
+ [![Twitter Follow](https://img.shields.io/twitter/follow/PlandexAI?style=social)](https://twitter.com/PlandexAI)
42
+
43
+ </div>
44
+
45
+ <p align="center">
46
+ <!-- Badges -->
47
+ <a href="https://github.com/plandex-ai/plandex/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a> <a href="https://github.com/plandex-ai/plandex/releases?q=cli"><img src="https://img.shields.io/github/v/release/plandex-ai/plandex?filter=cli*" alt="Release" /></a>
48
+ <a href="https://github.com/plandex-ai/plandex/releases?q=server"><img src="https://img.shields.io/github/v/release/plandex-ai/plandex?filter=server*" alt="Release" /></a>
49
+
50
+ <!-- <a href="https://github.com/your_username/your_project/issues">
51
+ <img src="https://img.shields.io/github/issues-closed/your_username/your_project.svg" alt="Issues Closed" />
52
+ </a> -->
53
+
54
+ </p>
55
+
56
+ <br />
57
+
58
+ <div align="center">
59
+ <a href="https://trendshift.io/repositories/8994" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8994" alt="plandex-ai%2Fplandex | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
60
+ </div>
61
+
62
+ <br>
63
+
64
+ <h1 align="center" >
65
+ An AI coding agent designed for large tasks and real world projects.<br/><br/>
66
+ </h1>
67
+
68
+ <!-- <h2 align="center">
69
+ Designed for large tasks and real world projects.<br/><br/>
70
+ </h2> -->
71
+ <br/>
72
+
73
+ <div align="center">
74
+ <a href="https://www.youtube.com/watch?v=SFSu2vNmlLk">
75
+ <img src="images/plandex-v2-yt.png" alt="Plandex v2 Demo Video" width="800">
76
+ </a>
77
+ </div>
78
+
79
+ <br/>
80
+
81
+ 💻  Plandex is a terminal-based AI development tool that can **plan and execute** large coding tasks that span many steps and touch dozens of files. It can handle up to 2M tokens of context directly (~100k per file), and can index directories with 20M tokens or more using tree-sitter project maps.
82
+
83
+ 🔬  **A cumulative diff review sandbox** keeps AI-generated changes separate from your project files until they are ready to go. Command execution is controlled so you can easily roll back and debug. Plandex helps you get the most out of AI without leaving behind a mess in your project.
84
+
85
+ 🧠  **Combine the best models** from Anthropic, OpenAI, Google, and open source providers to build entire features and apps with a robust terminal-based workflow.
86
+
87
+ 🚀  Plandex is capable of <strong>full autonomy</strong>—it can load relevant files, plan and implement changes, execute commands, and automatically debug—but it's also highly flexible and configurable, giving developers fine-grained control and a step-by-step review process when needed.
88
+
89
+ 💪  Plandex is designed to be resilient to <strong>large projects and files</strong>. If you've found that others tools struggle once your project gets past a certain size or the changes are too complex, give Plandex a shot.
90
+
91
+ ## Smart context management that works in big projects
92
+
93
+ - 🐘 **2M token effective context window** with default model pack. Plandex loads only what's needed for each step.
94
+
95
+ - 🗄️ **Reliable in large projects and files.** Easily generate, review, revise, and apply changes spanning dozens of files.
96
+
97
+ - 🗺️ **Fast project map generation** and syntax validation with tree-sitter. Supports 30+ languages.
98
+
99
+ - 💰 **Context caching** is used across the board for OpenAI, Anthropic, and Google models, reducing costs and latency.
100
+
101
+ ## Tight control or full autonomy—it's up to you
102
+
103
+ - 🚦 **Configurable autonomy:** go from full auto mode to fine-grained control depending on the task.
104
+
105
+ - 🐞 **Automated debugging** of terminal commands (like builds, linters, tests, deployments, and scripts). If you have Chrome installed, you can also automatically debug browser applications.
106
+
107
+ ## Tools that help you get production-ready results
108
+
109
+ - 💬 **A project-aware chat mode** that helps you flesh out ideas before moving to implementation. Also great for asking questions and learning about a codebase.
110
+
111
+ - 🧠 **Easily try + combine models** from multiple providers. Curated model packs offer different tradeoffs of capability, cost, and speed, as well as open source and provider-specific packs.
112
+
113
+ - 🛡️ **Reliable file edits** that prioritize correctness. While most edits are quick and cheap, Plandex validates both syntax and logic as needed, with multiple fallback layers when there are problems.
114
+
115
+ - 🔀 **Full-fledged version control** for every update to the plan, including branches for exploring multiple paths or comparing different models.
116
+
117
+ - 📂 **Git integration** with commit message generation and optional automatic commits.
118
+
119
+ ## Dev-friendly, easy to install
120
+
121
+ - 🧑‍💻 **REPL mode** with fuzzy auto-complete for commands and file loading. Just run `plandex` in any project to get started.
122
+
123
+ - 🛠️ **CLI interface** for scripting or piping data into context.
124
+
125
+ - 📦 **One-line, zero dependency CLI install**. Dockerized local mode for easily self-hosting the server. Cloud-hosting options for extra reliability and convenience.
126
+
127
+ ## Workflow  🔄
128
+
129
+ <img src="images/plandex-workflow.png" alt="Plandex workflow" width="100%"/>
130
+
131
+ ## Examples  🎥
132
+
133
+ <br/>
134
+
135
+ <div align="center">
136
+ <a href="https://www.youtube.com/watch?v=g-_76U_nK0Y">
137
+ <img src="images/plandex-browser-debug-yt.png" alt="Plandex Browser Debugging Example" width="800">
138
+ </a>
139
+ </div>
140
+
141
+ <br/>
142
+
143
+ ## Install  📥
144
+
145
+ ```bash
146
+ curl -sL https://plandex.ai/install.sh | bash
147
+ ```
148
+
149
+ **Note:** Windows is supported via [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). Plandex only works correctly on Windows in the WSL shell. It doesn't work in the Windows CMD prompt or PowerShell.
150
+
151
+ [More installation options.](https://docs.plandex.ai/install)
152
+
153
+ ## Hosting  ⚖️
154
+
155
+ | Option | Description |
156
+ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
157
+ | **Plandex Cloud** | Winding down as of 10/3/2025 and no longer accepting new users. <a href="https://plandex.ai/blog/winding-down">Learn more.</a> |
158
+ | **Self-hosted/Local Mode** | • Run Plandex locally with Docker or host on your own server.<br/>• Use your own [OpenRouter.ai](https://openrouter.ai) key (or [other model provider](https://docs.plandex.ai/models/model-providers) accounts and API keys).<br/>• Follow the [local-mode quickstart](https://docs.plandex.ai/hosting/self-hosting/local-mode-quickstart) to get started. |
159
+
160
+ ## Provider keys  🔑
161
+
162
+ <!-- If you're going with a 'BYO API Key' option above (whether cloud or self-hosted), you'll need to set API keys for the [model providers](https://docs.plandex.ai/models/model-providers) you're using: -->
163
+
164
+ ```bash
165
+ export OPENROUTER_API_KEY=... # if using OpenRouter.ai
166
+ ```
167
+
168
+ <br/>
169
+
170
+ ## Claude Pro/Max subscription  🖇️
171
+
172
+ If you have a Claude Pro or Max subscription, Plandex can use it when calling Anthropic models. You'll be asked if you want to connect a subscription the first time you run Plandex.
173
+
174
+ <br/>
175
+
176
+ ## Get started  🚀
177
+
178
+ First, `cd` into a **project directory** where you want to get something done or chat about the project. Make a new directory first with `mkdir your-project-dir` if you're starting on a new project.
179
+
180
+ ```bash
181
+ cd your-project-dir
182
+ ```
183
+
184
+ For a new project, you might also want to initialize a git repo. Plandex doesn't require that your project is in a git repo, but it does integrate well with git if you use it.
185
+
186
+ ```bash
187
+ git init
188
+ ```
189
+
190
+ Now start the Plandex REPL in your project:
191
+
192
+ ```bash
193
+ plandex
194
+ ```
195
+
196
+ or for short:
197
+
198
+ ```bash
199
+ pdx
200
+ ```
201
+
202
+ <!-- ☁️ _If you're using Plandex Cloud, you'll be prompted at this point to start a trial._
203
+
204
+ Then just give the REPL help text a quick read, and you're ready go. The REPL starts in _chat mode_ by default, which is good for fleshing out ideas before moving to implementation. Once the task is clear, Plandex will prompt you to switch to _tell mode_ to make a detailed plan and start writing code. -->
205
+
206
+ <br/>
207
+
208
+ ## Docs  🛠���
209
+
210
+ ### [👉  Full documentation.](https://docs.plandex.ai/)
211
+
212
+ <br/>
213
+
214
+ ## Discussion and discord  💬
215
+
216
+ Please feel free to give your feedback, ask questions, report a bug, or just hang out:
217
+
218
+ - [Discord](https://discord.gg/plandex-ai)
219
+ - [Discussions](https://github.com/plandex-ai/plandex/discussions)
220
+ - [Issues](https://github.com/plandex-ai/plandex/issues)
221
+
222
+ ## Follow and subscribe
223
+
224
+ - [Follow @PlandexAI](https://x.com/PlandexAI)
225
+ - [Follow @Danenania](https://x.com/Danenania) (Plandex's creator)
226
+ - [Subscribe on YouTube](https://x.com/PlandexAI)
227
+
228
+ <br/>
229
+
230
+ ## Contributors  👥
231
+
232
+ ⭐️  Please star, fork, explore, and contribute to Plandex. There's a lot of work to do and so much that can be improved.
233
+
234
+ [Here's an overview on setting up a development environment.](https://docs.plandex.ai/development)
app/.dockerignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ cli/
2
+ plandex-server
app/.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ .env
app/clear_local.sh ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+
3
+ # Get the absolute path to the script's directory, regardless of where it's run from
4
+ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
5
+
6
+ # Change to the app directory if we're not already there
7
+ cd "$SCRIPT_DIR"
8
+
9
+ echo "WARNING: This will delete all Plandex server data and reset the database."
10
+ echo "This action cannot be undone."
11
+ read -p "Are you sure you want to continue? (y/N) " -n 1 -r
12
+ echo
13
+ if [[ ! $REPLY =~ ^[Yy]$ ]]
14
+ then
15
+ echo "Reset cancelled."
16
+ exit 1
17
+ fi
18
+
19
+ echo "Resetting local mode..."
20
+ echo "Stopping containers and removing volumes..."
21
+
22
+ # Stop containers and remove volumes
23
+ docker compose down -v
24
+
25
+ echo "Database and data directories cleared. Server stopped."
app/cli/api/clients.go ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "math"
5
+ "math/rand"
6
+ "net"
7
+ "net/http"
8
+ "os"
9
+ "plandex-cli/auth"
10
+ "plandex-cli/types"
11
+ "time"
12
+ )
13
+
14
+ const dialTimeout = 10 * time.Second
15
+ const fastReqTimeout = 30 * time.Second
16
+ const slowReqTimeout = 5 * time.Minute
17
+
18
+ type Api struct{}
19
+
20
+ var CloudApiHost string
21
+ var Client types.ApiClient = (*Api)(nil)
22
+
23
+ func init() {
24
+ if os.Getenv("PLANDEX_ENV") == "development" {
25
+ CloudApiHost = os.Getenv("PLANDEX_API_HOST")
26
+ if CloudApiHost == "" {
27
+ CloudApiHost = "http://localhost:8099"
28
+ }
29
+ } else {
30
+ CloudApiHost = "https://api-v2.plandex.ai"
31
+ }
32
+ }
33
+
34
+ func GetApiHost() string {
35
+ if auth.Current == nil {
36
+ return CloudApiHost
37
+ } else if auth.Current.IsCloud {
38
+ return CloudApiHost
39
+ } else {
40
+ return auth.Current.Host
41
+ }
42
+ }
43
+
44
+ type authenticatedTransport struct {
45
+ underlyingTransport http.RoundTripper
46
+ }
47
+
48
+ func (t *authenticatedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
49
+ err := auth.SetAuthHeader(req)
50
+ if err != nil {
51
+ return nil, err
52
+ }
53
+ auth.SetVersionHeader(req)
54
+ return t.underlyingTransport.RoundTrip(req)
55
+ }
56
+
57
+ type unauthenticatedTransport struct {
58
+ underlyingTransport http.RoundTripper
59
+ }
60
+
61
+ func (t *unauthenticatedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
62
+ auth.SetVersionHeader(req)
63
+ return t.underlyingTransport.RoundTrip(req)
64
+ }
65
+
66
+ type retryTransport struct {
67
+ Base http.RoundTripper
68
+ MaxRetries int
69
+ BaseDelay time.Duration
70
+ MaxDelay time.Duration
71
+ Jitter time.Duration
72
+ RetryStatuses map[int]bool
73
+ }
74
+
75
+ func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
76
+ if t.Base == nil {
77
+ t.Base = http.DefaultTransport
78
+ }
79
+ var resp *http.Response
80
+ var err error
81
+
82
+ for attempt := 0; attempt <= t.MaxRetries; attempt++ {
83
+ resp, err = t.Base.RoundTrip(req)
84
+
85
+ // If there's a low-level error (e.g. network), retry unless it's a timeout, as these are often transient.
86
+ if err != nil {
87
+ if netErr, ok := err.(net.Error); ok {
88
+ if netErr.Timeout() {
89
+ return resp, err
90
+ }
91
+ }
92
+ // continue to next attempt
93
+ } else {
94
+ // If status code not in our RetryStatuses, return immediately.
95
+ if !t.RetryStatuses[resp.StatusCode] {
96
+ return resp, nil
97
+ }
98
+
99
+ // Close the body before retrying.
100
+ _ = resp.Body.Close()
101
+ }
102
+
103
+ // If we reached the max, break out of loop (will return last resp).
104
+ if attempt == t.MaxRetries {
105
+ break
106
+ }
107
+
108
+ // Exponential backoff + jitter
109
+ backoff := float64(t.BaseDelay) * math.Pow(2, float64(attempt))
110
+ if backoff > float64(t.MaxDelay) {
111
+ backoff = float64(t.MaxDelay)
112
+ }
113
+ sleepDuration := time.Duration(backoff) + time.Duration(rand.Int63n(int64(t.Jitter)))
114
+ time.Sleep(sleepDuration)
115
+ }
116
+ return resp, err
117
+ }
118
+
119
+ var netDialer = &net.Dialer{
120
+ Timeout: dialTimeout,
121
+ }
122
+
123
+ var baseTransport = &http.Transport{
124
+ Dial: netDialer.Dial,
125
+ }
126
+
127
+ var sharedRetryTransport = &retryTransport{
128
+ Base: baseTransport,
129
+ MaxRetries: 3,
130
+ BaseDelay: 500 * time.Millisecond,
131
+ MaxDelay: 5 * time.Second,
132
+ Jitter: 300 * time.Millisecond,
133
+ RetryStatuses: map[int]bool{502: true, 503: true, 504: true},
134
+ }
135
+
136
+ var unauthenticatedClient = &http.Client{
137
+ Transport: &unauthenticatedTransport{
138
+ underlyingTransport: sharedRetryTransport,
139
+ },
140
+ Timeout: fastReqTimeout,
141
+ }
142
+
143
+ var authenticatedFastClient = &http.Client{
144
+ Transport: &authenticatedTransport{
145
+ underlyingTransport: sharedRetryTransport,
146
+ },
147
+ Timeout: fastReqTimeout,
148
+ }
149
+
150
+ var authenticatedSlowClient = &http.Client{
151
+ Transport: &authenticatedTransport{
152
+ underlyingTransport: sharedRetryTransport,
153
+ },
154
+ Timeout: slowReqTimeout,
155
+ }
156
+
157
+ var authenticatedStreamingClient = &http.Client{
158
+ Transport: &authenticatedTransport{
159
+ underlyingTransport: sharedRetryTransport,
160
+ },
161
+ }
app/cli/api/errors.go ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "encoding/json"
5
+ "log"
6
+ "net/http"
7
+ "plandex-cli/auth"
8
+ "plandex-cli/term"
9
+ "strings"
10
+
11
+ shared "plandex-shared"
12
+ )
13
+
14
+ func HandleApiError(r *http.Response, errBody []byte) *shared.ApiError {
15
+ // Check if the response is JSON
16
+ if r.Header.Get("Content-Type") != "application/json" {
17
+ return &shared.ApiError{
18
+ Type: shared.ApiErrorTypeOther,
19
+ Status: r.StatusCode,
20
+ Msg: strings.TrimSpace(string(errBody)),
21
+ }
22
+ }
23
+
24
+ var apiError shared.ApiError
25
+ if err := json.Unmarshal(errBody, &apiError); err != nil {
26
+ log.Printf("Error unmarshalling JSON: %v\n", err)
27
+ return &shared.ApiError{
28
+ Type: shared.ApiErrorTypeOther,
29
+ Status: r.StatusCode,
30
+ Msg: strings.TrimSpace(string(errBody)),
31
+ }
32
+ }
33
+
34
+ // return error if token/auth refresh is needed
35
+ if apiError.Type == shared.ApiErrorTypeInvalidToken || apiError.Type == shared.ApiErrorTypeAuthOutdated {
36
+ return &apiError
37
+ }
38
+
39
+ term.HandleApiError(&apiError)
40
+
41
+ return &apiError
42
+ }
43
+
44
+ func refreshAuthIfNeeded(apiErr *shared.ApiError) (bool, *shared.ApiError) {
45
+ if apiErr.Type == shared.ApiErrorTypeInvalidToken {
46
+ err := auth.RefreshInvalidToken()
47
+ if err != nil {
48
+ return false, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: "error refreshing invalid token"}
49
+ }
50
+ return true, nil
51
+ } else if apiErr.Type == shared.ApiErrorTypeAuthOutdated {
52
+ err := auth.RefreshAuth()
53
+ if err != nil {
54
+ return false, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: "error refreshing auth"}
55
+ }
56
+
57
+ return true, nil
58
+ }
59
+
60
+ return false, apiErr
61
+ }
app/cli/api/methods.go ADDED
@@ -0,0 +1,2454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "log"
10
+ "net/http"
11
+ "plandex-cli/types"
12
+ "strings"
13
+
14
+ shared "plandex-shared"
15
+
16
+ "github.com/shopspring/decimal"
17
+ )
18
+
19
+ func (a *Api) CreateCliTrialSession() (string, *shared.ApiError) {
20
+ serverUrl := CloudApiHost + "/accounts/cli_trial_session"
21
+
22
+ resp, err := unauthenticatedClient.Post(serverUrl, "application/json", nil)
23
+
24
+ if err != nil {
25
+ return "", &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
26
+ }
27
+
28
+ defer resp.Body.Close()
29
+
30
+ if resp.StatusCode >= 400 {
31
+ errorBody, _ := io.ReadAll(resp.Body)
32
+ apiErr := HandleApiError(resp, errorBody)
33
+ return "", apiErr
34
+ }
35
+
36
+ bytes, err := io.ReadAll(resp.Body)
37
+
38
+ if err != nil {
39
+ return "", &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error reading response: %v", err)}
40
+ }
41
+
42
+ return string(bytes), nil
43
+ }
44
+
45
+ func (a *Api) GetCliTrialSession(token string) (*shared.SessionResponse, *shared.ApiError) {
46
+ serverUrl := fmt.Sprintf("%s/accounts/cli_trial_session/%s", CloudApiHost, token)
47
+
48
+ resp, err := unauthenticatedClient.Get(serverUrl)
49
+
50
+ if err != nil {
51
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
52
+ }
53
+
54
+ if resp.StatusCode == 404 {
55
+ return nil, nil
56
+ }
57
+
58
+ defer resp.Body.Close()
59
+
60
+ if resp.StatusCode >= 400 {
61
+ errorBody, _ := io.ReadAll(resp.Body)
62
+ apiErr := HandleApiError(resp, errorBody)
63
+ return nil, apiErr
64
+ }
65
+
66
+ var session shared.SessionResponse
67
+ err = json.NewDecoder(resp.Body).Decode(&session)
68
+ if err != nil {
69
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
70
+ }
71
+
72
+ return &session, nil
73
+ }
74
+
75
+ func (a *Api) CreateProject(req shared.CreateProjectRequest) (*shared.CreateProjectResponse, *shared.ApiError) {
76
+ serverUrl := GetApiHost() + "/projects"
77
+
78
+ reqBytes, err := json.Marshal(req)
79
+ if err != nil {
80
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
81
+ }
82
+
83
+ resp, err := authenticatedFastClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
84
+ if err != nil {
85
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
86
+ }
87
+ defer resp.Body.Close()
88
+
89
+ if resp.StatusCode >= 400 {
90
+ errorBody, _ := io.ReadAll(resp.Body)
91
+ apiErr := HandleApiError(resp, errorBody)
92
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
93
+ if authRefreshed {
94
+ return a.CreateProject(req)
95
+ }
96
+ return nil, apiErr
97
+ }
98
+
99
+ var respBody shared.CreateProjectResponse
100
+ err = json.NewDecoder(resp.Body).Decode(&respBody)
101
+ if err != nil {
102
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
103
+ }
104
+
105
+ return &respBody, nil
106
+ }
107
+
108
+ func (a *Api) ListProjects() ([]*shared.Project, *shared.ApiError) {
109
+ serverUrl := GetApiHost() + "/projects"
110
+ resp, err := authenticatedFastClient.Get(serverUrl)
111
+ if err != nil {
112
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
113
+ }
114
+ defer resp.Body.Close()
115
+
116
+ if resp.StatusCode >= 400 {
117
+ errorBody, _ := io.ReadAll(resp.Body)
118
+ apiErr := HandleApiError(resp, errorBody)
119
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
120
+ if authRefreshed {
121
+ return a.ListProjects()
122
+ }
123
+ return nil, apiErr
124
+ }
125
+
126
+ var projects []*shared.Project
127
+ err = json.NewDecoder(resp.Body).Decode(&projects)
128
+ if err != nil {
129
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
130
+ }
131
+
132
+ return projects, nil
133
+ }
134
+
135
+ func (a *Api) SetProjectPlan(projectId string, req shared.SetProjectPlanRequest) *shared.ApiError {
136
+ serverUrl := fmt.Sprintf("%s/projects/%s/set_plan", GetApiHost(), projectId)
137
+ reqBytes, err := json.Marshal(req)
138
+ if err != nil {
139
+ return &shared.ApiError{Msg: fmt.Sprintf("error marshalling request: %v", err)}
140
+ }
141
+
142
+ request, err := http.NewRequest(http.MethodPut, serverUrl, bytes.NewBuffer(reqBytes))
143
+ if err != nil {
144
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
145
+ }
146
+ request.Header.Set("Content-Type", "application/json")
147
+
148
+ resp, err := authenticatedFastClient.Do(request)
149
+ if err != nil {
150
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
151
+ }
152
+ defer resp.Body.Close()
153
+
154
+ if resp.StatusCode >= 400 {
155
+ errorBody, _ := io.ReadAll(resp.Body)
156
+ apiErr := HandleApiError(resp, errorBody)
157
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
158
+ if didRefresh {
159
+ return a.SetProjectPlan(projectId, req)
160
+ }
161
+ return apiErr
162
+ }
163
+
164
+ return nil
165
+ }
166
+
167
+ func (a *Api) RenameProject(projectId string, req shared.RenameProjectRequest) *shared.ApiError {
168
+ serverUrl := fmt.Sprintf("%s/projects/%s/rename", GetApiHost(), projectId)
169
+ reqBytes, err := json.Marshal(req)
170
+ if err != nil {
171
+ return &shared.ApiError{Msg: fmt.Sprintf("error marshalling request: %v", err)}
172
+ }
173
+
174
+ request, err := http.NewRequest(http.MethodPut, serverUrl, bytes.NewBuffer(reqBytes))
175
+ if err != nil {
176
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
177
+ }
178
+ request.Header.Set("Content-Type", "application/json")
179
+
180
+ resp, err := authenticatedFastClient.Do(request)
181
+ if err != nil {
182
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
183
+ }
184
+ defer resp.Body.Close()
185
+
186
+ if resp.StatusCode >= 400 {
187
+ errorBody, _ := io.ReadAll(resp.Body)
188
+ apiErr := HandleApiError(resp, errorBody)
189
+
190
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
191
+ if didRefresh {
192
+ return a.RenameProject(projectId, req)
193
+ }
194
+ return apiErr
195
+ }
196
+
197
+ return nil
198
+ }
199
+ func (a *Api) ListPlans(projectIds []string) ([]*shared.Plan, *shared.ApiError) {
200
+ serverUrl := fmt.Sprintf("%s/plans?", GetApiHost())
201
+ parts := []string{}
202
+ for _, projectId := range projectIds {
203
+ parts = append(parts, fmt.Sprintf("projectId=%s", projectId))
204
+ }
205
+ serverUrl += strings.Join(parts, "&")
206
+
207
+ resp, err := authenticatedFastClient.Get(serverUrl)
208
+ if err != nil {
209
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
210
+ }
211
+ defer resp.Body.Close()
212
+
213
+ if resp.StatusCode >= 400 {
214
+ errorBody, _ := io.ReadAll(resp.Body)
215
+
216
+ apiErr := HandleApiError(resp, errorBody)
217
+
218
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
219
+ if didRefresh {
220
+ return a.ListPlans(projectIds)
221
+ }
222
+ return nil, apiErr
223
+ }
224
+
225
+ var plans []*shared.Plan
226
+ err = json.NewDecoder(resp.Body).Decode(&plans)
227
+ if err != nil {
228
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
229
+ }
230
+
231
+ return plans, nil
232
+ }
233
+
234
+ func (a *Api) ListArchivedPlans(projectIds []string) ([]*shared.Plan, *shared.ApiError) {
235
+ serverUrl := fmt.Sprintf("%s/plans/archive?", GetApiHost())
236
+ parts := []string{}
237
+ for _, projectId := range projectIds {
238
+ parts = append(parts, fmt.Sprintf("projectId=%s", projectId))
239
+ }
240
+ serverUrl += strings.Join(parts, "&")
241
+
242
+ resp, err := authenticatedFastClient.Get(serverUrl)
243
+ if err != nil {
244
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
245
+ }
246
+ defer resp.Body.Close()
247
+
248
+ if resp.StatusCode >= 400 {
249
+ errorBody, _ := io.ReadAll(resp.Body)
250
+ apiErr := HandleApiError(resp, errorBody)
251
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
252
+ if authRefreshed {
253
+ return a.ListArchivedPlans(projectIds)
254
+ }
255
+ return nil, apiErr
256
+ }
257
+
258
+ var plans []*shared.Plan
259
+ err = json.NewDecoder(resp.Body).Decode(&plans)
260
+ if err != nil {
261
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
262
+ }
263
+
264
+ return plans, nil
265
+ }
266
+
267
+ func (a *Api) ListPlansRunning(projectIds []string, includeRecent bool) (*shared.ListPlansRunningResponse, *shared.ApiError) {
268
+ serverUrl := fmt.Sprintf("%s/plans/ps?", GetApiHost())
269
+ parts := []string{}
270
+ for _, projectId := range projectIds {
271
+ parts = append(parts, fmt.Sprintf("projectId=%s", projectId))
272
+ }
273
+ serverUrl += strings.Join(parts, "&")
274
+ if includeRecent {
275
+ serverUrl += "&recent=true"
276
+ }
277
+
278
+ resp, err := authenticatedFastClient.Get(serverUrl)
279
+ if err != nil {
280
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
281
+ }
282
+ defer resp.Body.Close()
283
+
284
+ if resp.StatusCode >= 400 {
285
+ errorBody, _ := io.ReadAll(resp.Body)
286
+ apiErr := HandleApiError(resp, errorBody)
287
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
288
+ if authRefreshed {
289
+ return a.ListPlansRunning(projectIds, includeRecent)
290
+ }
291
+ return nil, apiErr
292
+ }
293
+
294
+ var respBody *shared.ListPlansRunningResponse
295
+ err = json.NewDecoder(resp.Body).Decode(&respBody)
296
+ if err != nil {
297
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
298
+ }
299
+
300
+ return respBody, nil
301
+ }
302
+
303
+ func (a *Api) GetCurrentBranchByPlanId(projectId string, req shared.GetCurrentBranchByPlanIdRequest) (map[string]*shared.Branch, *shared.ApiError) {
304
+ serverUrl := fmt.Sprintf("%s/projects/%s/plans/current_branches", GetApiHost(), projectId)
305
+
306
+ reqBytes, err := json.Marshal(req)
307
+ if err != nil {
308
+ return nil, &shared.ApiError{Msg: fmt.Sprintf("error marshalling request: %v", err)}
309
+ }
310
+
311
+ request, err := http.NewRequest(http.MethodPost, serverUrl, bytes.NewBuffer(reqBytes))
312
+
313
+ if err != nil {
314
+ return nil, &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
315
+ }
316
+
317
+ request.Header.Set("Content-Type", "application/json")
318
+
319
+ resp, err := authenticatedFastClient.Do(request)
320
+
321
+ if err != nil {
322
+ return nil, &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
323
+ }
324
+ defer resp.Body.Close()
325
+
326
+ if resp.StatusCode >= 400 {
327
+ errorBody, _ := io.ReadAll(resp.Body)
328
+
329
+ apiErr := HandleApiError(resp, errorBody)
330
+
331
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
332
+ if didRefresh {
333
+ return a.GetCurrentBranchByPlanId(projectId, req)
334
+ }
335
+ return nil, apiErr
336
+ }
337
+
338
+ var respBody map[string]*shared.Branch
339
+ err = json.NewDecoder(resp.Body).Decode(&respBody)
340
+ if err != nil {
341
+ return nil, &shared.ApiError{Msg: fmt.Sprintf("error decoding response: %v", err)}
342
+ }
343
+
344
+ return respBody, nil
345
+ }
346
+
347
+ func (a *Api) CreatePlan(projectId string, req shared.CreatePlanRequest) (*shared.CreatePlanResponse, *shared.ApiError) {
348
+ serverUrl := fmt.Sprintf("%s/projects/%s/plans", GetApiHost(), projectId)
349
+ reqBytes, err := json.Marshal(req)
350
+ if err != nil {
351
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
352
+ }
353
+
354
+ resp, err := authenticatedFastClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
355
+ if err != nil {
356
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
357
+ }
358
+ defer resp.Body.Close()
359
+
360
+ if resp.StatusCode >= 400 {
361
+ errorBody, _ := io.ReadAll(resp.Body)
362
+ apiErr := HandleApiError(resp, errorBody)
363
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
364
+ if authRefreshed {
365
+ return a.CreatePlan(projectId, req)
366
+ }
367
+ return nil, apiErr
368
+ }
369
+
370
+ var respBody shared.CreatePlanResponse
371
+ err = json.NewDecoder(resp.Body).Decode(&respBody)
372
+ if err != nil {
373
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
374
+ }
375
+
376
+ return &respBody, nil
377
+ }
378
+
379
+ func (a *Api) GetPlan(planId string) (*shared.Plan, *shared.ApiError) {
380
+ serverUrl := fmt.Sprintf("%s/plans/%s", GetApiHost(), planId)
381
+
382
+ resp, err := authenticatedFastClient.Get(serverUrl)
383
+ if err != nil {
384
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
385
+ }
386
+
387
+ defer resp.Body.Close()
388
+
389
+ if resp.StatusCode >= 400 {
390
+ errorBody, _ := io.ReadAll(resp.Body)
391
+ apiErr := HandleApiError(resp, errorBody)
392
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
393
+ if authRefreshed {
394
+ return a.GetPlan(planId)
395
+ }
396
+ return nil, apiErr
397
+ }
398
+
399
+ var plan shared.Plan
400
+ err = json.NewDecoder(resp.Body).Decode(&plan)
401
+ if err != nil {
402
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
403
+ }
404
+
405
+ return &plan, nil
406
+ }
407
+
408
+ func (a *Api) DeletePlan(planId string) *shared.ApiError {
409
+ serverUrl := fmt.Sprintf("%s/plans/%s", GetApiHost(), planId)
410
+
411
+ req, err := http.NewRequest(http.MethodDelete, serverUrl, nil)
412
+ if err != nil {
413
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
414
+ }
415
+
416
+ resp, err := authenticatedFastClient.Do(req)
417
+ if err != nil {
418
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
419
+ }
420
+ defer resp.Body.Close()
421
+
422
+ if resp.StatusCode >= 400 {
423
+ errorBody, _ := io.ReadAll(resp.Body)
424
+ apiErr := HandleApiError(resp, errorBody)
425
+
426
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
427
+ if didRefresh {
428
+ return a.DeletePlan(planId)
429
+ }
430
+ return apiErr
431
+ }
432
+
433
+ return nil
434
+ }
435
+
436
+ func (a *Api) DeleteAllPlans(projectId string) *shared.ApiError {
437
+ serverUrl := fmt.Sprintf("%s/projects/%s/plans", GetApiHost(), projectId)
438
+
439
+ req, err := http.NewRequest(http.MethodDelete, serverUrl, nil)
440
+ if err != nil {
441
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
442
+ }
443
+
444
+ resp, err := authenticatedFastClient.Do(req)
445
+ if err != nil {
446
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
447
+ }
448
+ defer resp.Body.Close()
449
+
450
+ if resp.StatusCode >= 400 {
451
+ errorBody, _ := io.ReadAll(resp.Body)
452
+ apiErr := HandleApiError(resp, errorBody)
453
+
454
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
455
+
456
+ if didRefresh {
457
+ return a.DeleteAllPlans(projectId)
458
+ }
459
+ return apiErr
460
+ }
461
+
462
+ return nil
463
+ }
464
+
465
+ func (a *Api) TellPlan(planId, branch string, req shared.TellPlanRequest, onStream types.OnStreamPlan) *shared.ApiError {
466
+
467
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/tell", GetApiHost(), planId, branch)
468
+ reqBytes, err := json.Marshal(req)
469
+ if err != nil {
470
+ return &shared.ApiError{Msg: fmt.Sprintf("error marshalling request: %v", err)}
471
+ }
472
+
473
+ request, err := http.NewRequest(http.MethodPost, serverUrl, bytes.NewBuffer(reqBytes))
474
+ if err != nil {
475
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
476
+ }
477
+ request.Header.Set("Content-Type", "application/json")
478
+
479
+ var client *http.Client
480
+ if req.ConnectStream {
481
+ client = authenticatedStreamingClient
482
+ } else {
483
+ client = authenticatedFastClient
484
+ }
485
+
486
+ resp, err := client.Do(request)
487
+ if err != nil {
488
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
489
+ }
490
+
491
+ if resp.StatusCode >= 400 {
492
+ errorBody, _ := io.ReadAll(resp.Body)
493
+ apiErr := HandleApiError(resp, errorBody)
494
+
495
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
496
+
497
+ if didRefresh {
498
+ return a.TellPlan(planId, branch, req, onStream)
499
+ }
500
+ return apiErr
501
+ }
502
+
503
+ if req.ConnectStream {
504
+ log.Println("Connecting stream")
505
+ connectPlanRespStream(resp.Body, onStream)
506
+ } else {
507
+ // log.Println("Background exec - not connecting stream")
508
+ resp.Body.Close()
509
+ }
510
+
511
+ return nil
512
+ }
513
+
514
+ func (a *Api) BuildPlan(planId, branch string, req shared.BuildPlanRequest, onStream types.OnStreamPlan) *shared.ApiError {
515
+
516
+ log.Println("Calling BuildPlan")
517
+
518
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/build", GetApiHost(), planId, branch)
519
+ reqBytes, err := json.Marshal(req)
520
+ if err != nil {
521
+ return &shared.ApiError{Msg: fmt.Sprintf("error marshalling request: %v", err)}
522
+ }
523
+
524
+ request, err := http.NewRequest(http.MethodPatch, serverUrl, bytes.NewBuffer(reqBytes))
525
+ if err != nil {
526
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
527
+ }
528
+ request.Header.Set("Content-Type", "application/json")
529
+
530
+ var client *http.Client
531
+ if req.ConnectStream {
532
+ client = authenticatedStreamingClient
533
+ } else {
534
+ client = authenticatedFastClient
535
+ }
536
+
537
+ resp, err := client.Do(request)
538
+ if err != nil {
539
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
540
+ }
541
+
542
+ if resp.StatusCode >= 400 {
543
+ log.Println("Error response from build plan", resp.StatusCode)
544
+
545
+ errorBody, _ := io.ReadAll(resp.Body)
546
+ apiErr := HandleApiError(resp, errorBody)
547
+
548
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
549
+
550
+ if didRefresh {
551
+ return a.BuildPlan(planId, branch, req, onStream)
552
+ }
553
+ return apiErr
554
+ }
555
+
556
+ if req.ConnectStream {
557
+ log.Println("Connecting stream")
558
+ connectPlanRespStream(resp.Body, onStream)
559
+ } else {
560
+ // log.Println("Background exec - not connecting stream")
561
+ resp.Body.Close()
562
+ }
563
+
564
+ return nil
565
+ }
566
+
567
+ func (a *Api) RespondMissingFile(planId, branch string, req shared.RespondMissingFileRequest) *shared.ApiError {
568
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/respond_missing_file", GetApiHost(), planId, branch)
569
+
570
+ reqBytes, err := json.Marshal(req)
571
+ if err != nil {
572
+ return &shared.ApiError{Msg: fmt.Sprintf("error marshalling request: %v", err)}
573
+ }
574
+
575
+ request, err := http.NewRequest(http.MethodPost, serverUrl, bytes.NewBuffer(reqBytes))
576
+ if err != nil {
577
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
578
+ }
579
+ request.Header.Set("Content-Type", "application/json")
580
+
581
+ resp, err := authenticatedFastClient.Do(request)
582
+ if err != nil {
583
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
584
+ }
585
+
586
+ if resp.StatusCode >= 400 {
587
+ errorBody, _ := io.ReadAll(resp.Body)
588
+ apiErr := HandleApiError(resp, errorBody)
589
+
590
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
591
+
592
+ if didRefresh {
593
+ return a.RespondMissingFile(planId, branch, req)
594
+ }
595
+ return apiErr
596
+ }
597
+
598
+ return nil
599
+
600
+ }
601
+
602
+ func (a *Api) ConnectPlan(planId, branch string, onStream types.OnStreamPlan) *shared.ApiError {
603
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/connect", GetApiHost(), planId, branch)
604
+
605
+ req, err := http.NewRequest(http.MethodPatch, serverUrl, nil)
606
+ if err != nil {
607
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
608
+ }
609
+
610
+ resp, err := authenticatedStreamingClient.Do(req)
611
+ if err != nil {
612
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
613
+ }
614
+
615
+ if resp.StatusCode >= 400 {
616
+ errorBody, _ := io.ReadAll(resp.Body)
617
+ apiErr := HandleApiError(resp, errorBody)
618
+
619
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
620
+
621
+ if didRefresh {
622
+ return a.ConnectPlan(planId, branch, onStream)
623
+ }
624
+
625
+ return apiErr
626
+ }
627
+
628
+ connectPlanRespStream(resp.Body, onStream)
629
+
630
+ return nil
631
+ }
632
+
633
+ func (a *Api) StopPlan(ctx context.Context, planId, branch string) *shared.ApiError {
634
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/stop", GetApiHost(), planId, branch)
635
+
636
+ req, err := http.NewRequestWithContext(ctx, http.MethodDelete, serverUrl, nil)
637
+ if err != nil {
638
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
639
+ }
640
+
641
+ resp, err := authenticatedFastClient.Do(req)
642
+ if err != nil {
643
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
644
+ }
645
+ defer resp.Body.Close()
646
+
647
+ if resp.StatusCode >= 400 {
648
+ errorBody, _ := io.ReadAll(resp.Body)
649
+ apiErr := HandleApiError(resp, errorBody)
650
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
651
+ if didRefresh {
652
+ return a.StopPlan(ctx, planId, branch)
653
+ }
654
+ return apiErr
655
+ }
656
+
657
+ return nil
658
+ }
659
+
660
+ func (a *Api) GetCurrentPlanState(planId, branch string) (*shared.CurrentPlanState, *shared.ApiError) {
661
+ return a.getCurrentPlanState(planId, branch, "")
662
+ }
663
+
664
+ func (a *Api) GetCurrentPlanStateAtSha(planId, sha string) (*shared.CurrentPlanState, *shared.ApiError) {
665
+ return a.getCurrentPlanState(planId, "", sha)
666
+ }
667
+
668
+ func (a *Api) getCurrentPlanState(planId, branch, sha string) (*shared.CurrentPlanState, *shared.ApiError) {
669
+ var serverUrl string
670
+ if sha != "" {
671
+ serverUrl = fmt.Sprintf("%s/plans/%s/current_plan/%s", GetApiHost(), planId, sha)
672
+ } else {
673
+ serverUrl = fmt.Sprintf("%s/plans/%s/%s/current_plan", GetApiHost(), planId, branch)
674
+ }
675
+
676
+ resp, err := authenticatedFastClient.Get(serverUrl)
677
+ if err != nil {
678
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
679
+ }
680
+ defer resp.Body.Close()
681
+
682
+ if resp.StatusCode >= 400 {
683
+ errorBody, _ := io.ReadAll(resp.Body)
684
+ apiErr := HandleApiError(resp, errorBody)
685
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
686
+ if authRefreshed {
687
+ return a.getCurrentPlanState(planId, branch, sha)
688
+ }
689
+ return nil, apiErr
690
+ }
691
+
692
+ var state shared.CurrentPlanState
693
+ err = json.NewDecoder(resp.Body).Decode(&state)
694
+ if err != nil {
695
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
696
+ }
697
+
698
+ return &state, nil
699
+ }
700
+
701
+ func (a *Api) ApplyPlan(planId, branch string, req shared.ApplyPlanRequest) (string, *shared.ApiError) {
702
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/apply", GetApiHost(), planId, branch)
703
+
704
+ reqBytes, err := json.Marshal(req)
705
+ if err != nil {
706
+ return "", &shared.ApiError{Msg: fmt.Sprintf("error marshalling request: %v", err)}
707
+ }
708
+
709
+ request, err := http.NewRequest(http.MethodPatch, serverUrl, bytes.NewBuffer(reqBytes))
710
+ if err != nil {
711
+ return "", &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
712
+ }
713
+ request.Header.Set("Content-Type", "application/json")
714
+
715
+ resp, err := authenticatedFastClient.Do(request)
716
+ if err != nil {
717
+ return "", &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
718
+ }
719
+ defer resp.Body.Close()
720
+
721
+ if resp.StatusCode >= 400 {
722
+ errorBody, _ := io.ReadAll(resp.Body)
723
+ apiErr := HandleApiError(resp, errorBody)
724
+
725
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
726
+ if didRefresh {
727
+ return a.ApplyPlan(planId, branch, req)
728
+ }
729
+ return "", apiErr
730
+ }
731
+
732
+ // Reading the body on success
733
+ responseData, err := io.ReadAll(resp.Body)
734
+ if err != nil {
735
+ return "", &shared.ApiError{Msg: fmt.Sprintf("error reading response body: %v", err)}
736
+ }
737
+
738
+ return string(responseData), nil
739
+ }
740
+
741
+ func (a *Api) ArchivePlan(planId string) *shared.ApiError {
742
+ serverUrl := fmt.Sprintf("%s/plans/%s/archive", GetApiHost(), planId)
743
+
744
+ req, err := http.NewRequest(http.MethodPatch, serverUrl, nil)
745
+ if err != nil {
746
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
747
+ }
748
+
749
+ resp, err := authenticatedFastClient.Do(req)
750
+ if err != nil {
751
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
752
+ }
753
+ defer resp.Body.Close()
754
+
755
+ if resp.StatusCode >= 400 {
756
+ errorBody, _ := io.ReadAll(resp.Body)
757
+ apiErr := HandleApiError(resp, errorBody)
758
+
759
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
760
+ if didRefresh {
761
+ return a.ArchivePlan(planId)
762
+ }
763
+ return apiErr
764
+ }
765
+
766
+ return nil
767
+ }
768
+
769
+ func (a *Api) UnarchivePlan(planId string) *shared.ApiError {
770
+ serverUrl := fmt.Sprintf("%s/plans/%s/unarchive", GetApiHost(), planId)
771
+
772
+ req, err := http.NewRequest(http.MethodPatch, serverUrl, nil)
773
+ if err != nil {
774
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
775
+ }
776
+
777
+ resp, err := authenticatedFastClient.Do(req)
778
+ if err != nil {
779
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
780
+ }
781
+ defer resp.Body.Close()
782
+
783
+ if resp.StatusCode >= 400 {
784
+ errorBody, _ := io.ReadAll(resp.Body)
785
+ apiErr := HandleApiError(resp, errorBody)
786
+
787
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
788
+ if didRefresh {
789
+ return a.ArchivePlan(planId)
790
+ }
791
+ return apiErr
792
+ }
793
+
794
+ return nil
795
+ }
796
+
797
+ func (a *Api) RenamePlan(planId string, name string) *shared.ApiError {
798
+ serverUrl := fmt.Sprintf("%s/plans/%s/rename", GetApiHost(), planId)
799
+
800
+ reqBytes, err := json.Marshal(shared.RenamePlanRequest{Name: name})
801
+ if err != nil {
802
+ return &shared.ApiError{Msg: fmt.Sprintf("error marshalling request: %v", err)}
803
+ }
804
+
805
+ request, err := http.NewRequest(http.MethodPatch, serverUrl, bytes.NewBuffer(reqBytes))
806
+
807
+ if err != nil {
808
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
809
+ }
810
+
811
+ request.Header.Set("Content-Type", "application/json")
812
+
813
+ resp, err := authenticatedFastClient.Do(request)
814
+
815
+ if err != nil {
816
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
817
+ }
818
+
819
+ defer resp.Body.Close()
820
+
821
+ if resp.StatusCode >= 400 {
822
+ errorBody, _ := io.ReadAll(resp.Body)
823
+
824
+ apiErr := HandleApiError(resp, errorBody)
825
+
826
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
827
+ if didRefresh {
828
+ return a.RenamePlan(planId, name)
829
+ }
830
+ return apiErr
831
+ }
832
+
833
+ return nil
834
+ }
835
+
836
+ func (a *Api) RejectAllChanges(planId, branch string) *shared.ApiError {
837
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/reject_all", GetApiHost(), planId, branch)
838
+
839
+ req, err := http.NewRequest(http.MethodPatch, serverUrl, nil)
840
+ if err != nil {
841
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
842
+ }
843
+
844
+ resp, err := authenticatedFastClient.Do(req)
845
+ if err != nil {
846
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
847
+ }
848
+ defer resp.Body.Close()
849
+
850
+ if resp.StatusCode >= 400 {
851
+ errorBody, _ := io.ReadAll(resp.Body)
852
+ apiErr := HandleApiError(resp, errorBody)
853
+
854
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
855
+ if didRefresh {
856
+ return a.RejectAllChanges(planId, branch)
857
+ }
858
+ return apiErr
859
+ }
860
+
861
+ return nil
862
+ }
863
+
864
+ func (a *Api) RejectFile(planId, branch, filePath string) *shared.ApiError {
865
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/reject_file", GetApiHost(), planId, branch)
866
+
867
+ reqBytes, err := json.Marshal(shared.RejectFileRequest{FilePath: filePath})
868
+
869
+ if err != nil {
870
+ return &shared.ApiError{Msg: fmt.Sprintf("error marshalling request: %v", err)}
871
+ }
872
+
873
+ req, err := http.NewRequest(http.MethodPatch, serverUrl, bytes.NewBuffer(reqBytes))
874
+ if err != nil {
875
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
876
+ }
877
+ req.Header.Set("Content-Type", "application/json")
878
+
879
+ resp, err := authenticatedFastClient.Do(req)
880
+ if err != nil {
881
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
882
+ }
883
+ defer resp.Body.Close()
884
+
885
+ if resp.StatusCode >= 400 {
886
+ errorBody, _ := io.ReadAll(resp.Body)
887
+ apiErr := HandleApiError(resp, errorBody)
888
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
889
+ if didRefresh {
890
+ a.RejectFile(planId, branch, filePath)
891
+ }
892
+ return apiErr
893
+ }
894
+
895
+ return nil
896
+ }
897
+
898
+ func (a *Api) RejectFiles(planId, branch string, paths []string) *shared.ApiError {
899
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/reject_files", GetApiHost(), planId, branch)
900
+
901
+ reqBytes, err := json.Marshal(shared.RejectFilesRequest{Paths: paths})
902
+
903
+ if err != nil {
904
+ return &shared.ApiError{Msg: fmt.Sprintf("error marshalling request: %v", err)}
905
+ }
906
+
907
+ req, err := http.NewRequest(http.MethodPatch, serverUrl, bytes.NewBuffer(reqBytes))
908
+ if err != nil {
909
+ return &shared.ApiError{Msg: fmt.Sprintf("error creating request: %v", err)}
910
+ }
911
+ req.Header.Set("Content-Type", "application/json")
912
+
913
+ resp, err := authenticatedFastClient.Do(req)
914
+ if err != nil {
915
+ return &shared.ApiError{Msg: fmt.Sprintf("error sending request: %v", err)}
916
+ }
917
+ defer resp.Body.Close()
918
+
919
+ if resp.StatusCode >= 400 {
920
+ errorBody, _ := io.ReadAll(resp.Body)
921
+ apiErr := HandleApiError(resp, errorBody)
922
+ didRefresh, apiErr := refreshAuthIfNeeded(apiErr)
923
+ if didRefresh {
924
+ a.RejectFiles(planId, branch, paths)
925
+ }
926
+ return apiErr
927
+ }
928
+
929
+ return nil
930
+ }
931
+
932
+ func (a *Api) LoadContext(planId, branch string, req shared.LoadContextRequest) (*shared.LoadContextResponse, *shared.ApiError) {
933
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/context", GetApiHost(), planId, branch)
934
+ reqBytes, err := json.Marshal(req)
935
+ if err != nil {
936
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
937
+ }
938
+
939
+ // use the slow client since we may be uploading relatively large files
940
+ resp, err := authenticatedSlowClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
941
+ if err != nil {
942
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
943
+ }
944
+ defer resp.Body.Close()
945
+
946
+ if resp.StatusCode >= 400 {
947
+ errorBody, _ := io.ReadAll(resp.Body)
948
+ apiErr := HandleApiError(resp, errorBody)
949
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
950
+ if authRefreshed {
951
+ return a.LoadContext(planId, branch, req)
952
+ }
953
+ return nil, apiErr
954
+ }
955
+
956
+ var loadContextResponse shared.LoadContextResponse
957
+ err = json.NewDecoder(resp.Body).Decode(&loadContextResponse)
958
+ if err != nil {
959
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
960
+ }
961
+
962
+ return &loadContextResponse, nil
963
+ }
964
+
965
+ func (a *Api) UpdateContext(planId, branch string, req shared.UpdateContextRequest) (*shared.UpdateContextResponse, *shared.ApiError) {
966
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/context", GetApiHost(), planId, branch)
967
+
968
+ reqBytes, err := json.Marshal(req)
969
+ if err != nil {
970
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
971
+ }
972
+
973
+ request, err := http.NewRequest(http.MethodPut, serverUrl, bytes.NewBuffer(reqBytes))
974
+
975
+ if err != nil {
976
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %v", err)}
977
+ }
978
+
979
+ request.Header.Set("Content-Type", "application/json")
980
+
981
+ // use the slow client since we may be uploading relatively large files
982
+ resp, err := authenticatedSlowClient.Do(request)
983
+ if err != nil {
984
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
985
+ }
986
+ defer resp.Body.Close()
987
+
988
+ if resp.StatusCode >= 400 {
989
+ errorBody, _ := io.ReadAll(resp.Body)
990
+ apiErr := HandleApiError(resp, errorBody)
991
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
992
+ if authRefreshed {
993
+ return a.UpdateContext(planId, branch, req)
994
+ }
995
+ return nil, apiErr
996
+ }
997
+
998
+ var updateContextResponse shared.UpdateContextResponse
999
+ err = json.NewDecoder(resp.Body).Decode(&updateContextResponse)
1000
+ if err != nil {
1001
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1002
+ }
1003
+
1004
+ return &updateContextResponse, nil
1005
+ }
1006
+
1007
+ func (a *Api) DeleteContext(planId, branch string, req shared.DeleteContextRequest) (*shared.DeleteContextResponse, *shared.ApiError) {
1008
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/context", GetApiHost(), planId, branch)
1009
+ reqBytes, err := json.Marshal(req)
1010
+ if err != nil {
1011
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
1012
+ }
1013
+
1014
+ request, err := http.NewRequest(http.MethodDelete, serverUrl, bytes.NewBuffer(reqBytes))
1015
+ if err != nil {
1016
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %v", err)}
1017
+ }
1018
+ request.Header.Set("Content-Type", "application/json")
1019
+
1020
+ resp, err := authenticatedFastClient.Do(request)
1021
+ if err != nil {
1022
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1023
+ }
1024
+ defer resp.Body.Close()
1025
+
1026
+ if resp.StatusCode >= 400 {
1027
+ errorBody, _ := io.ReadAll(resp.Body)
1028
+ apiErr := HandleApiError(resp, errorBody)
1029
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1030
+ if authRefreshed {
1031
+ return a.DeleteContext(planId, branch, req)
1032
+ }
1033
+ return nil, apiErr
1034
+ }
1035
+
1036
+ var deleteContextResponse shared.DeleteContextResponse
1037
+ err = json.NewDecoder(resp.Body).Decode(&deleteContextResponse)
1038
+ if err != nil {
1039
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1040
+ }
1041
+
1042
+ return &deleteContextResponse, nil
1043
+ }
1044
+
1045
+ func (a *Api) ListContext(planId, branch string) ([]*shared.Context, *shared.ApiError) {
1046
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/context", GetApiHost(), planId, branch)
1047
+
1048
+ resp, err := authenticatedFastClient.Get(serverUrl)
1049
+ if err != nil {
1050
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1051
+ }
1052
+ defer resp.Body.Close()
1053
+
1054
+ if resp.StatusCode >= 400 {
1055
+ errorBody, _ := io.ReadAll(resp.Body)
1056
+ apiErr := HandleApiError(resp, errorBody)
1057
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1058
+ if authRefreshed {
1059
+ return a.ListContext(planId, branch)
1060
+ }
1061
+ return nil, apiErr
1062
+ }
1063
+
1064
+ var contexts []*shared.Context
1065
+ err = json.NewDecoder(resp.Body).Decode(&contexts)
1066
+ if err != nil {
1067
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1068
+ }
1069
+
1070
+ return contexts, nil
1071
+ }
1072
+
1073
+ func (a *Api) LoadCachedFileMap(planId, branch string, req shared.LoadCachedFileMapRequest) (*shared.LoadCachedFileMapResponse, *shared.ApiError) {
1074
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/load_cached_file_map", GetApiHost(), planId, branch)
1075
+ reqBytes, err := json.Marshal(req)
1076
+ if err != nil {
1077
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
1078
+ }
1079
+
1080
+ resp, err := authenticatedFastClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
1081
+ if err != nil {
1082
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1083
+ }
1084
+ defer resp.Body.Close()
1085
+
1086
+ if resp.StatusCode >= 400 {
1087
+ errorBody, _ := io.ReadAll(resp.Body)
1088
+ apiErr := HandleApiError(resp, errorBody)
1089
+ return nil, apiErr
1090
+ }
1091
+
1092
+ var loadResp shared.LoadCachedFileMapResponse
1093
+ err = json.NewDecoder(resp.Body).Decode(&loadResp)
1094
+ if err != nil {
1095
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1096
+ }
1097
+
1098
+ return &loadResp, nil
1099
+ }
1100
+
1101
+ func (a *Api) ListConvo(planId, branch string) ([]*shared.ConvoMessage, *shared.ApiError) {
1102
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/convo", GetApiHost(), planId, branch)
1103
+
1104
+ resp, err := authenticatedFastClient.Get(serverUrl)
1105
+ if err != nil {
1106
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1107
+ }
1108
+ defer resp.Body.Close()
1109
+
1110
+ if resp.StatusCode >= 400 {
1111
+ errorBody, _ := io.ReadAll(resp.Body)
1112
+ apiErr := HandleApiError(resp, errorBody)
1113
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1114
+ if authRefreshed {
1115
+ return a.ListConvo(planId, branch)
1116
+ }
1117
+ return nil, apiErr
1118
+ }
1119
+
1120
+ var convos []*shared.ConvoMessage
1121
+ err = json.NewDecoder(resp.Body).Decode(&convos)
1122
+ if err != nil {
1123
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1124
+ }
1125
+
1126
+ return convos, nil
1127
+ }
1128
+
1129
+ func (a *Api) GetPlanStatus(planId, branch string) (string, *shared.ApiError) {
1130
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/status", GetApiHost(), planId, branch)
1131
+
1132
+ resp, err := authenticatedFastClient.Get(serverUrl)
1133
+ if err != nil {
1134
+ return "", &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1135
+ }
1136
+ defer resp.Body.Close()
1137
+
1138
+ if resp.StatusCode >= 400 {
1139
+ errorBody, _ := io.ReadAll(resp.Body)
1140
+ apiErr := HandleApiError(resp, errorBody)
1141
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1142
+ if authRefreshed {
1143
+ return a.GetPlanStatus(planId, branch)
1144
+ }
1145
+ return "", apiErr
1146
+ }
1147
+
1148
+ body, err := io.ReadAll(resp.Body)
1149
+
1150
+ if err != nil {
1151
+ return "", &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error reading response body: %v", err)}
1152
+ }
1153
+
1154
+ return string(body), nil
1155
+ }
1156
+
1157
+ func (a *Api) GetPlanDiffs(planId, branch string, plain bool) (string, *shared.ApiError) {
1158
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/diffs", GetApiHost(), planId, branch)
1159
+
1160
+ if plain {
1161
+ serverUrl += "?plain=true"
1162
+ }
1163
+
1164
+ resp, err := authenticatedFastClient.Get(serverUrl)
1165
+ if err != nil {
1166
+ return "", &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1167
+ }
1168
+ defer resp.Body.Close()
1169
+
1170
+ if resp.StatusCode >= 400 {
1171
+ errorBody, _ := io.ReadAll(resp.Body)
1172
+ apiErr := HandleApiError(resp, errorBody)
1173
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1174
+ if authRefreshed {
1175
+ return a.GetPlanDiffs(planId, branch, plain)
1176
+ }
1177
+ return "", apiErr
1178
+ }
1179
+
1180
+ body, err := io.ReadAll(resp.Body)
1181
+
1182
+ if err != nil {
1183
+ return "", &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error reading response body: %v", err)}
1184
+ }
1185
+
1186
+ return string(body), nil
1187
+ }
1188
+
1189
+ func (a *Api) ListLogs(planId, branch string) (*shared.LogResponse, *shared.ApiError) {
1190
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/logs", GetApiHost(), planId, branch)
1191
+
1192
+ resp, err := authenticatedFastClient.Get(serverUrl)
1193
+ if err != nil {
1194
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1195
+ }
1196
+
1197
+ defer resp.Body.Close()
1198
+
1199
+ if resp.StatusCode >= 400 {
1200
+ errorBody, _ := io.ReadAll(resp.Body)
1201
+ apiErr := HandleApiError(resp, errorBody)
1202
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1203
+ if authRefreshed {
1204
+ return a.ListLogs(planId, branch)
1205
+ }
1206
+ return nil, apiErr
1207
+ }
1208
+
1209
+ var logs shared.LogResponse
1210
+ err = json.NewDecoder(resp.Body).Decode(&logs)
1211
+ if err != nil {
1212
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1213
+ }
1214
+
1215
+ return &logs, nil
1216
+ }
1217
+
1218
+ func (a *Api) RewindPlan(planId, branch string, req shared.RewindPlanRequest) (*shared.RewindPlanResponse, *shared.ApiError) {
1219
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/rewind", GetApiHost(), planId, branch)
1220
+ reqBytes, err := json.Marshal(req)
1221
+ if err != nil {
1222
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
1223
+ }
1224
+
1225
+ request, err := http.NewRequest(http.MethodPatch, serverUrl, bytes.NewBuffer(reqBytes))
1226
+ if err != nil {
1227
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %v", err)}
1228
+ }
1229
+ request.Header.Set("Content-Type", "application/json")
1230
+
1231
+ resp, err := authenticatedFastClient.Do(request)
1232
+ if err != nil {
1233
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1234
+ }
1235
+
1236
+ defer resp.Body.Close()
1237
+
1238
+ if resp.StatusCode >= 400 {
1239
+ errorBody, _ := io.ReadAll(resp.Body)
1240
+ apiErr := HandleApiError(resp, errorBody)
1241
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1242
+ if authRefreshed {
1243
+ return a.RewindPlan(planId, branch, req)
1244
+ }
1245
+ return nil, apiErr
1246
+ }
1247
+
1248
+ var rewindPlanResponse shared.RewindPlanResponse
1249
+ err = json.NewDecoder(resp.Body).Decode(&rewindPlanResponse)
1250
+ if err != nil {
1251
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1252
+ }
1253
+
1254
+ return &rewindPlanResponse, nil
1255
+ }
1256
+
1257
+ func (a *Api) SignIn(req shared.SignInRequest, customHost string) (*shared.SessionResponse, *shared.ApiError) {
1258
+ host := customHost
1259
+ if host == "" {
1260
+ host = CloudApiHost
1261
+ }
1262
+ serverUrl := host + "/accounts/sign_in"
1263
+ reqBytes, err := json.Marshal(req)
1264
+ if err != nil {
1265
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
1266
+ }
1267
+
1268
+ resp, err := unauthenticatedClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
1269
+ if err != nil {
1270
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1271
+ }
1272
+ defer resp.Body.Close()
1273
+
1274
+ if resp.StatusCode >= 400 {
1275
+ errorBody, _ := io.ReadAll(resp.Body)
1276
+ apiErr := HandleApiError(resp, errorBody)
1277
+ return nil, apiErr
1278
+ }
1279
+
1280
+ var sessionResponse shared.SessionResponse
1281
+ err = json.NewDecoder(resp.Body).Decode(&sessionResponse)
1282
+ if err != nil {
1283
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1284
+ }
1285
+
1286
+ return &sessionResponse, nil
1287
+ }
1288
+
1289
+ func (a *Api) CreateAccount(req shared.CreateAccountRequest, customHost string) (*shared.SessionResponse, *shared.ApiError) {
1290
+ host := customHost
1291
+ if host == "" {
1292
+ host = CloudApiHost
1293
+ }
1294
+ serverUrl := host + "/accounts"
1295
+ reqBytes, err := json.Marshal(req)
1296
+ if err != nil {
1297
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
1298
+ }
1299
+
1300
+ resp, err := unauthenticatedClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
1301
+ if err != nil {
1302
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1303
+ }
1304
+ defer resp.Body.Close()
1305
+
1306
+ if resp.StatusCode >= 400 {
1307
+ errorBody, _ := io.ReadAll(resp.Body)
1308
+ apiErr := HandleApiError(resp, errorBody)
1309
+ return nil, apiErr
1310
+ }
1311
+
1312
+ var sessionResponse shared.SessionResponse
1313
+ err = json.NewDecoder(resp.Body).Decode(&sessionResponse)
1314
+ if err != nil {
1315
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1316
+ }
1317
+
1318
+ return &sessionResponse, nil
1319
+ }
1320
+
1321
+ func (a *Api) CreateOrg(req shared.CreateOrgRequest) (*shared.CreateOrgResponse, *shared.ApiError) {
1322
+ serverUrl := GetApiHost() + "/orgs"
1323
+ reqBytes, err := json.Marshal(req)
1324
+ if err != nil {
1325
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
1326
+ }
1327
+
1328
+ resp, err := authenticatedFastClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
1329
+ if err != nil {
1330
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1331
+ }
1332
+ defer resp.Body.Close()
1333
+
1334
+ if resp.StatusCode >= 400 {
1335
+ errorBody, _ := io.ReadAll(resp.Body)
1336
+ apiErr := HandleApiError(resp, errorBody)
1337
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1338
+ if authRefreshed {
1339
+ return a.CreateOrg(req)
1340
+ }
1341
+ return nil, apiErr
1342
+ }
1343
+
1344
+ var createOrgResponse shared.CreateOrgResponse
1345
+ err = json.NewDecoder(resp.Body).Decode(&createOrgResponse)
1346
+ if err != nil {
1347
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1348
+ }
1349
+
1350
+ return &createOrgResponse, nil
1351
+ }
1352
+
1353
+ func (a *Api) GetOrgSession() (*shared.Org, *shared.ApiError) {
1354
+ serverUrl := GetApiHost() + "/orgs/session"
1355
+ resp, err := authenticatedFastClient.Get(serverUrl)
1356
+ if err != nil {
1357
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1358
+ }
1359
+ defer resp.Body.Close()
1360
+
1361
+ if resp.StatusCode >= 400 {
1362
+ errorBody, _ := io.ReadAll(resp.Body)
1363
+ apiErr := HandleApiError(resp, errorBody)
1364
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1365
+ if authRefreshed {
1366
+ return a.GetOrgSession()
1367
+ }
1368
+ return nil, apiErr
1369
+ }
1370
+
1371
+ var org *shared.Org
1372
+
1373
+ err = json.NewDecoder(resp.Body).Decode(&org)
1374
+
1375
+ if err != nil {
1376
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1377
+ }
1378
+
1379
+ return org, nil
1380
+ }
1381
+
1382
+ func (a *Api) ListOrgs() ([]*shared.Org, *shared.ApiError) {
1383
+ serverUrl := GetApiHost() + "/orgs"
1384
+ resp, err := authenticatedFastClient.Get(serverUrl)
1385
+ if err != nil {
1386
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1387
+ }
1388
+ defer resp.Body.Close()
1389
+
1390
+ if resp.StatusCode >= 400 {
1391
+ errorBody, _ := io.ReadAll(resp.Body)
1392
+ apiErr := HandleApiError(resp, errorBody)
1393
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1394
+ if authRefreshed {
1395
+ return a.ListOrgs()
1396
+ }
1397
+ return nil, apiErr
1398
+ }
1399
+
1400
+ var orgs []*shared.Org
1401
+ err = json.NewDecoder(resp.Body).Decode(&orgs)
1402
+ if err != nil {
1403
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1404
+ }
1405
+
1406
+ return orgs, nil
1407
+ }
1408
+
1409
+ func (a *Api) GetOrgUserConfig() (*shared.OrgUserConfig, *shared.ApiError) {
1410
+ serverUrl := GetApiHost() + "/org_user_config"
1411
+ resp, err := authenticatedFastClient.Get(serverUrl)
1412
+ if err != nil {
1413
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1414
+ }
1415
+ defer resp.Body.Close()
1416
+
1417
+ if resp.StatusCode >= 400 {
1418
+ errorBody, _ := io.ReadAll(resp.Body)
1419
+ apiErr := HandleApiError(resp, errorBody)
1420
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1421
+ if authRefreshed {
1422
+ return a.GetOrgUserConfig()
1423
+ }
1424
+ return nil, apiErr
1425
+ }
1426
+
1427
+ var orgUserConfig shared.OrgUserConfig
1428
+ err = json.NewDecoder(resp.Body).Decode(&orgUserConfig)
1429
+ if err != nil {
1430
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1431
+ }
1432
+
1433
+ return &orgUserConfig, nil
1434
+ }
1435
+
1436
+ func (a *Api) UpdateOrgUserConfig(c shared.OrgUserConfig) *shared.ApiError {
1437
+ serverUrl := GetApiHost() + "/org_user_config"
1438
+
1439
+ reqBytes, err := json.Marshal(c)
1440
+
1441
+ if err != nil {
1442
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
1443
+ }
1444
+
1445
+ request, err := http.NewRequest(http.MethodPut, serverUrl, bytes.NewBuffer(reqBytes))
1446
+ if err != nil {
1447
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %v", err)}
1448
+ }
1449
+
1450
+ request.Header.Set("Content-Type", "application/json")
1451
+
1452
+ resp, err := authenticatedFastClient.Do(request)
1453
+ if err != nil {
1454
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1455
+ }
1456
+ defer resp.Body.Close()
1457
+
1458
+ if resp.StatusCode >= 400 {
1459
+ errorBody, _ := io.ReadAll(resp.Body)
1460
+ apiErr := HandleApiError(resp, errorBody)
1461
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1462
+ if authRefreshed {
1463
+ return a.UpdateOrgUserConfig(c)
1464
+ }
1465
+ return apiErr
1466
+ }
1467
+
1468
+ return nil
1469
+ }
1470
+
1471
+ func (a *Api) DeleteUser(userId string) *shared.ApiError {
1472
+ serverUrl := fmt.Sprintf("%s/orgs/users/%s", GetApiHost(), userId)
1473
+ req, err := http.NewRequest(http.MethodDelete, serverUrl, nil)
1474
+ if err != nil {
1475
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %v", err)}
1476
+ }
1477
+
1478
+ resp, err := authenticatedFastClient.Do(req)
1479
+ if err != nil {
1480
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1481
+ }
1482
+ defer resp.Body.Close()
1483
+
1484
+ if resp.StatusCode >= 400 {
1485
+ errorBody, _ := io.ReadAll(resp.Body)
1486
+ apiErr := HandleApiError(resp, errorBody)
1487
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1488
+ if authRefreshed {
1489
+ return a.DeleteUser(userId)
1490
+ }
1491
+ return apiErr
1492
+ }
1493
+
1494
+ return nil
1495
+ }
1496
+
1497
+ func (a *Api) ListOrgRoles() ([]*shared.OrgRole, *shared.ApiError) {
1498
+ serverUrl := GetApiHost() + "/orgs/roles"
1499
+ resp, err := authenticatedFastClient.Get(serverUrl)
1500
+ if err != nil {
1501
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %s", err)}
1502
+ }
1503
+ defer resp.Body.Close()
1504
+
1505
+ if resp.StatusCode >= 400 {
1506
+ errorBody, _ := io.ReadAll(resp.Body)
1507
+
1508
+ apiErr := HandleApiError(resp, errorBody)
1509
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1510
+ if authRefreshed {
1511
+ return a.ListOrgRoles()
1512
+ }
1513
+ return nil, apiErr
1514
+ }
1515
+
1516
+ var roles []*shared.OrgRole
1517
+ err = json.NewDecoder(resp.Body).Decode(&roles)
1518
+ if err != nil {
1519
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %s", err)}
1520
+ }
1521
+
1522
+ return roles, nil
1523
+ }
1524
+
1525
+ func (a *Api) InviteUser(req shared.InviteRequest) *shared.ApiError {
1526
+ serverUrl := GetApiHost() + "/invites"
1527
+ reqBytes, err := json.Marshal(req)
1528
+ if err != nil {
1529
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
1530
+ }
1531
+
1532
+ resp, err := authenticatedFastClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
1533
+ if err != nil {
1534
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1535
+ }
1536
+ defer resp.Body.Close()
1537
+
1538
+ if resp.StatusCode >= 400 {
1539
+ errorBody, _ := io.ReadAll(resp.Body)
1540
+ apiErr := HandleApiError(resp, errorBody)
1541
+
1542
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1543
+ if authRefreshed {
1544
+ return a.InviteUser(req)
1545
+ }
1546
+ return apiErr
1547
+ }
1548
+
1549
+ return nil
1550
+ }
1551
+
1552
+ func (a *Api) ListPendingInvites() ([]*shared.Invite, *shared.ApiError) {
1553
+ serverUrl := GetApiHost() + "/invites/pending"
1554
+ resp, err := authenticatedFastClient.Get(serverUrl)
1555
+ if err != nil {
1556
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1557
+ }
1558
+ defer resp.Body.Close()
1559
+
1560
+ if resp.StatusCode >= 400 {
1561
+ errorBody, _ := io.ReadAll(resp.Body)
1562
+ apiErr := HandleApiError(resp, errorBody)
1563
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1564
+ if authRefreshed {
1565
+ return a.ListPendingInvites()
1566
+ }
1567
+ return nil, apiErr
1568
+ }
1569
+
1570
+ var invites []*shared.Invite
1571
+ err = json.NewDecoder(resp.Body).Decode(&invites)
1572
+ if err != nil {
1573
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1574
+ }
1575
+
1576
+ return invites, nil
1577
+ }
1578
+
1579
+ func (a *Api) ListAcceptedInvites() ([]*shared.Invite, *shared.ApiError) {
1580
+ serverUrl := GetApiHost() + "/invites/accepted"
1581
+ resp, err := authenticatedFastClient.Get(serverUrl)
1582
+ if err != nil {
1583
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1584
+ }
1585
+ defer resp.Body.Close()
1586
+
1587
+ if resp.StatusCode >= 400 {
1588
+ errorBody, _ := io.ReadAll(resp.Body)
1589
+ apiErr := HandleApiError(resp, errorBody)
1590
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1591
+ if authRefreshed {
1592
+ return a.ListAcceptedInvites()
1593
+ }
1594
+ return nil, apiErr
1595
+ }
1596
+
1597
+ var invites []*shared.Invite
1598
+ err = json.NewDecoder(resp.Body).Decode(&invites)
1599
+ if err != nil {
1600
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1601
+ }
1602
+
1603
+ return invites, nil
1604
+ }
1605
+
1606
+ func (a *Api) ListAllInvites() ([]*shared.Invite, *shared.ApiError) {
1607
+ serverUrl := GetApiHost() + "/invites/all"
1608
+ resp, err := authenticatedFastClient.Get(serverUrl)
1609
+ if err != nil {
1610
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1611
+ }
1612
+ defer resp.Body.Close()
1613
+
1614
+ if resp.StatusCode >= 400 {
1615
+ errorBody, _ := io.ReadAll(resp.Body)
1616
+ apiErr := HandleApiError(resp, errorBody)
1617
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1618
+ if authRefreshed {
1619
+ return a.ListAllInvites()
1620
+ }
1621
+ return nil, apiErr
1622
+ }
1623
+
1624
+ var invites []*shared.Invite
1625
+ err = json.NewDecoder(resp.Body).Decode(&invites)
1626
+ if err != nil {
1627
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1628
+ }
1629
+
1630
+ return invites, nil
1631
+ }
1632
+
1633
+ func (a *Api) DeleteInvite(inviteId string) *shared.ApiError {
1634
+ serverUrl := fmt.Sprintf("%s/invites/%s", GetApiHost(), inviteId)
1635
+ req, err := http.NewRequest(http.MethodDelete, serverUrl, nil)
1636
+ if err != nil {
1637
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %v", err)}
1638
+ }
1639
+
1640
+ resp, err := authenticatedFastClient.Do(req)
1641
+ if err != nil {
1642
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1643
+ }
1644
+ defer resp.Body.Close()
1645
+
1646
+ if resp.StatusCode >= 400 {
1647
+ errorBody, _ := io.ReadAll(resp.Body)
1648
+ apiErr := HandleApiError(resp, errorBody)
1649
+
1650
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1651
+ if authRefreshed {
1652
+ return a.DeleteInvite(inviteId)
1653
+ }
1654
+ return apiErr
1655
+ }
1656
+
1657
+ return nil
1658
+ }
1659
+
1660
+ func (a *Api) CreateEmailVerification(email, customHost, userId string) (*shared.CreateEmailVerificationResponse, *shared.ApiError) {
1661
+ host := customHost
1662
+ if host == "" {
1663
+ host = CloudApiHost
1664
+ }
1665
+ serverUrl := host + "/accounts/email_verifications"
1666
+ req := shared.CreateEmailVerificationRequest{Email: email, UserId: userId}
1667
+ reqBytes, err := json.Marshal(req)
1668
+ if err != nil {
1669
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
1670
+ }
1671
+
1672
+ resp, err := unauthenticatedClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
1673
+ if err != nil {
1674
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1675
+ }
1676
+ defer resp.Body.Close()
1677
+
1678
+ if resp.StatusCode >= 400 {
1679
+ errorBody, _ := io.ReadAll(resp.Body)
1680
+ return nil, HandleApiError(resp, errorBody)
1681
+ }
1682
+
1683
+ var verificationResponse shared.CreateEmailVerificationResponse
1684
+ err = json.NewDecoder(resp.Body).Decode(&verificationResponse)
1685
+ if err != nil {
1686
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1687
+ }
1688
+
1689
+ return &verificationResponse, nil
1690
+ }
1691
+
1692
+ func (a *Api) CreateSignInCode() (string, *shared.ApiError) {
1693
+ serverUrl := GetApiHost() + "/accounts/sign_in_codes"
1694
+ resp, err := authenticatedFastClient.Post(serverUrl, "application/json", nil)
1695
+ if err != nil {
1696
+ return "", &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1697
+ }
1698
+ defer resp.Body.Close()
1699
+
1700
+ if resp.StatusCode >= 400 {
1701
+ errorBody, _ := io.ReadAll(resp.Body)
1702
+ apiErr := HandleApiError(resp, errorBody)
1703
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1704
+ if authRefreshed {
1705
+ return a.CreateSignInCode()
1706
+ }
1707
+ return "", apiErr
1708
+ }
1709
+
1710
+ var signInCode string
1711
+ body, err := io.ReadAll(resp.Body)
1712
+ if err != nil {
1713
+ return "", &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error reading response body: %v", err)}
1714
+ }
1715
+ signInCode = string(body)
1716
+
1717
+ return signInCode, nil
1718
+ }
1719
+
1720
+ func (a *Api) SignOut() *shared.ApiError {
1721
+ serverUrl := GetApiHost() + "/accounts/sign_out"
1722
+
1723
+ req, err := http.NewRequest(http.MethodPost, serverUrl, nil)
1724
+ if err != nil {
1725
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %v", err)}
1726
+ }
1727
+
1728
+ resp, err := authenticatedFastClient.Do(req)
1729
+ if err != nil {
1730
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1731
+ }
1732
+ defer resp.Body.Close()
1733
+
1734
+ if resp.StatusCode >= 400 {
1735
+ errorBody, _ := io.ReadAll(resp.Body)
1736
+ return HandleApiError(resp, errorBody)
1737
+ }
1738
+
1739
+ return nil
1740
+ }
1741
+
1742
+ func (a *Api) ListUsers() (*shared.ListUsersResponse, *shared.ApiError) {
1743
+ serverUrl := GetApiHost() + "/users"
1744
+ resp, err := authenticatedFastClient.Get(serverUrl)
1745
+ if err != nil {
1746
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
1747
+ }
1748
+ defer resp.Body.Close()
1749
+
1750
+ if resp.StatusCode >= 400 {
1751
+ errorBody, _ := io.ReadAll(resp.Body)
1752
+ apiErr := HandleApiError(resp, errorBody)
1753
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1754
+ if authRefreshed {
1755
+ return a.ListUsers()
1756
+ }
1757
+ return nil, apiErr
1758
+ }
1759
+
1760
+ var r *shared.ListUsersResponse
1761
+ err = json.NewDecoder(resp.Body).Decode(&r)
1762
+ if err != nil {
1763
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
1764
+ }
1765
+
1766
+ return r, nil
1767
+ }
1768
+
1769
+ func (a *Api) ListBranches(planId string) ([]*shared.Branch, *shared.ApiError) {
1770
+ serverUrl := fmt.Sprintf("%s/plans/%s/branches", GetApiHost(), planId)
1771
+
1772
+ resp, err := authenticatedFastClient.Get(serverUrl)
1773
+ if err != nil {
1774
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %s", err)}
1775
+ }
1776
+ defer resp.Body.Close()
1777
+
1778
+ if resp.StatusCode >= 400 {
1779
+ errorBody, _ := io.ReadAll(resp.Body)
1780
+
1781
+ apiErr := HandleApiError(resp, errorBody)
1782
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1783
+ if authRefreshed {
1784
+ return a.ListBranches(planId)
1785
+ }
1786
+ return nil, apiErr
1787
+ }
1788
+
1789
+ var branches []*shared.Branch
1790
+ err = json.NewDecoder(resp.Body).Decode(&branches)
1791
+ if err != nil {
1792
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %s", err)}
1793
+ }
1794
+
1795
+ return branches, nil
1796
+ }
1797
+
1798
+ func (a *Api) CreateBranch(planId, branch string, req shared.CreateBranchRequest) *shared.ApiError {
1799
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/branches", GetApiHost(), planId, branch)
1800
+
1801
+ reqBytes, err := json.Marshal(req)
1802
+ if err != nil {
1803
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %s", err)}
1804
+ }
1805
+
1806
+ resp, err := authenticatedFastClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
1807
+ if err != nil {
1808
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %s", err)}
1809
+ }
1810
+ defer resp.Body.Close()
1811
+
1812
+ if resp.StatusCode >= 400 {
1813
+ errorBody, _ := io.ReadAll(resp.Body)
1814
+
1815
+ apiErr := HandleApiError(resp, errorBody)
1816
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1817
+ if authRefreshed {
1818
+ return a.CreateBranch(planId, branch, req)
1819
+ }
1820
+ return apiErr
1821
+ }
1822
+
1823
+ return nil
1824
+ }
1825
+
1826
+ func (a *Api) DeleteBranch(planId, branch string) *shared.ApiError {
1827
+ serverUrl := fmt.Sprintf("%s/plans/%s/branches/%s", GetApiHost(), planId, branch)
1828
+
1829
+ req, err := http.NewRequest(http.MethodDelete, serverUrl, nil)
1830
+ if err != nil {
1831
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %s", err)}
1832
+ }
1833
+
1834
+ resp, err := authenticatedFastClient.Do(req)
1835
+ if err != nil {
1836
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %s", err)}
1837
+ }
1838
+ defer resp.Body.Close()
1839
+
1840
+ if resp.StatusCode >= 400 {
1841
+ errorBody, _ := io.ReadAll(resp.Body)
1842
+
1843
+ apiErr := HandleApiError(resp, errorBody)
1844
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1845
+ if authRefreshed {
1846
+ return a.DeleteBranch(planId, branch)
1847
+ }
1848
+ return apiErr
1849
+ }
1850
+
1851
+ return nil
1852
+ }
1853
+
1854
+ func (a *Api) GetSettings(planId, branch string) (*shared.PlanSettings, *shared.ApiError) {
1855
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/settings", GetApiHost(), planId, branch)
1856
+
1857
+ resp, err := authenticatedFastClient.Get(serverUrl)
1858
+
1859
+ if err != nil {
1860
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %s", err)}
1861
+ }
1862
+ defer resp.Body.Close()
1863
+
1864
+ if resp.StatusCode >= 400 {
1865
+ errorBody, _ := io.ReadAll(resp.Body)
1866
+ apiErr := HandleApiError(resp, errorBody)
1867
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1868
+ if authRefreshed {
1869
+ return a.GetSettings(planId, branch)
1870
+ }
1871
+ return nil, apiErr
1872
+ }
1873
+
1874
+ var settings shared.PlanSettings
1875
+ err = json.NewDecoder(resp.Body).Decode(&settings)
1876
+ if err != nil {
1877
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %s", err)}
1878
+ }
1879
+
1880
+ return &settings, nil
1881
+ }
1882
+
1883
+ func (a *Api) UpdateSettings(planId, branch string, req shared.UpdateSettingsRequest) (*shared.UpdateSettingsResponse, *shared.ApiError) {
1884
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/settings", GetApiHost(), planId, branch)
1885
+
1886
+ reqBytes, err := json.Marshal(req)
1887
+ if err != nil {
1888
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %s", err)}
1889
+ }
1890
+
1891
+ // log.Println("UpdateSettings", string(reqBytes))
1892
+
1893
+ request, err := http.NewRequest(http.MethodPut, serverUrl, bytes.NewBuffer(reqBytes))
1894
+ if err != nil {
1895
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %s", err)}
1896
+ }
1897
+ request.Header.Set("Content-Type", "application/json")
1898
+
1899
+ resp, err := authenticatedFastClient.Do(request)
1900
+ if err != nil {
1901
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %s", err)}
1902
+ }
1903
+ defer resp.Body.Close()
1904
+
1905
+ if resp.StatusCode >= 400 {
1906
+ errorBody, _ := io.ReadAll(resp.Body)
1907
+
1908
+ apiErr := HandleApiError(resp, errorBody)
1909
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1910
+ if authRefreshed {
1911
+ return a.UpdateSettings(planId, branch, req)
1912
+ }
1913
+ return nil, apiErr
1914
+ }
1915
+
1916
+ var updateRes shared.UpdateSettingsResponse
1917
+ err = json.NewDecoder(resp.Body).Decode(&updateRes)
1918
+ if err != nil {
1919
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %s", err)}
1920
+ }
1921
+
1922
+ return &updateRes, nil
1923
+
1924
+ }
1925
+
1926
+ func (a *Api) GetOrgDefaultSettings() (*shared.PlanSettings, *shared.ApiError) {
1927
+ serverUrl := fmt.Sprintf("%s/default_settings", GetApiHost())
1928
+
1929
+ resp, err := authenticatedFastClient.Get(serverUrl)
1930
+
1931
+ if err != nil {
1932
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %s", err)}
1933
+ }
1934
+ defer resp.Body.Close()
1935
+
1936
+ if resp.StatusCode >= 400 {
1937
+ errorBody, _ := io.ReadAll(resp.Body)
1938
+ apiErr := HandleApiError(resp, errorBody)
1939
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1940
+ if authRefreshed {
1941
+ return a.GetOrgDefaultSettings()
1942
+ }
1943
+ return nil, apiErr
1944
+ }
1945
+
1946
+ var settings shared.PlanSettings
1947
+ err = json.NewDecoder(resp.Body).Decode(&settings)
1948
+ if err != nil {
1949
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %s", err)}
1950
+ }
1951
+
1952
+ return &settings, nil
1953
+ }
1954
+
1955
+ func (a *Api) UpdateOrgDefaultSettings(req shared.UpdateSettingsRequest) (*shared.UpdateSettingsResponse, *shared.ApiError) {
1956
+ serverUrl := fmt.Sprintf("%s/default_settings", GetApiHost())
1957
+
1958
+ reqBytes, err := json.Marshal(req)
1959
+ if err != nil {
1960
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %s", err)}
1961
+ }
1962
+
1963
+ // log.Println("UpdateSettings", string(reqBytes))
1964
+
1965
+ request, err := http.NewRequest(http.MethodPut, serverUrl, bytes.NewBuffer(reqBytes))
1966
+ if err != nil {
1967
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %s", err)}
1968
+ }
1969
+ request.Header.Set("Content-Type", "application/json")
1970
+
1971
+ resp, err := authenticatedFastClient.Do(request)
1972
+ if err != nil {
1973
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %s", err)}
1974
+ }
1975
+ defer resp.Body.Close()
1976
+
1977
+ if resp.StatusCode >= 400 {
1978
+ errorBody, _ := io.ReadAll(resp.Body)
1979
+
1980
+ apiErr := HandleApiError(resp, errorBody)
1981
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
1982
+ if authRefreshed {
1983
+ return a.UpdateOrgDefaultSettings(req)
1984
+ }
1985
+ return nil, apiErr
1986
+ }
1987
+
1988
+ var updateRes shared.UpdateSettingsResponse
1989
+ err = json.NewDecoder(resp.Body).Decode(&updateRes)
1990
+ if err != nil {
1991
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %s", err)}
1992
+ }
1993
+
1994
+ return &updateRes, nil
1995
+ }
1996
+
1997
+ func (a *Api) GetPlanConfig(planId string) (*shared.PlanConfig, *shared.ApiError) {
1998
+ serverUrl := fmt.Sprintf("%s/plans/%s/config", GetApiHost(), planId)
1999
+
2000
+ resp, err := authenticatedFastClient.Get(serverUrl)
2001
+ if err != nil {
2002
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2003
+ }
2004
+ defer resp.Body.Close()
2005
+
2006
+ if resp.StatusCode >= 400 {
2007
+ errorBody, _ := io.ReadAll(resp.Body)
2008
+ apiErr := HandleApiError(resp, errorBody)
2009
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2010
+ if authRefreshed {
2011
+ return a.GetPlanConfig(planId)
2012
+ }
2013
+ return nil, apiErr
2014
+ }
2015
+
2016
+ var res shared.GetPlanConfigResponse
2017
+ err = json.NewDecoder(resp.Body).Decode(&res)
2018
+ if err != nil {
2019
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2020
+ }
2021
+
2022
+ return res.Config, nil
2023
+ }
2024
+
2025
+ func (a *Api) UpdatePlanConfig(planId string, req shared.UpdatePlanConfigRequest) *shared.ApiError {
2026
+ serverUrl := fmt.Sprintf("%s/plans/%s/config", GetApiHost(), planId)
2027
+
2028
+ reqBytes, err := json.Marshal(req)
2029
+ if err != nil {
2030
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
2031
+ }
2032
+
2033
+ request, err := http.NewRequest(http.MethodPut, serverUrl, bytes.NewBuffer(reqBytes))
2034
+ if err != nil {
2035
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %v", err)}
2036
+ }
2037
+ request.Header.Set("Content-Type", "application/json")
2038
+
2039
+ resp, err := authenticatedFastClient.Do(request)
2040
+ if err != nil {
2041
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2042
+ }
2043
+ defer resp.Body.Close()
2044
+
2045
+ if resp.StatusCode >= 400 {
2046
+ errorBody, _ := io.ReadAll(resp.Body)
2047
+ apiErr := HandleApiError(resp, errorBody)
2048
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2049
+ if authRefreshed {
2050
+ return a.UpdatePlanConfig(planId, req)
2051
+ }
2052
+ return apiErr
2053
+ }
2054
+
2055
+ return nil
2056
+ }
2057
+
2058
+ func (a *Api) GetDefaultPlanConfig() (*shared.PlanConfig, *shared.ApiError) {
2059
+ serverUrl := fmt.Sprintf("%s/default_plan_config", GetApiHost())
2060
+
2061
+ resp, err := authenticatedFastClient.Get(serverUrl)
2062
+ if err != nil {
2063
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2064
+ }
2065
+ defer resp.Body.Close()
2066
+
2067
+ if resp.StatusCode >= 400 {
2068
+ errorBody, _ := io.ReadAll(resp.Body)
2069
+ apiErr := HandleApiError(resp, errorBody)
2070
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2071
+ if authRefreshed {
2072
+ return a.GetDefaultPlanConfig()
2073
+ }
2074
+ return nil, apiErr
2075
+ }
2076
+
2077
+ var res shared.GetDefaultPlanConfigResponse
2078
+ err = json.NewDecoder(resp.Body).Decode(&res)
2079
+ if err != nil {
2080
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2081
+ }
2082
+
2083
+ return res.Config, nil
2084
+ }
2085
+
2086
+ func (a *Api) UpdateDefaultPlanConfig(req shared.UpdateDefaultPlanConfigRequest) *shared.ApiError {
2087
+ serverUrl := fmt.Sprintf("%s/default_plan_config", GetApiHost())
2088
+
2089
+ reqBytes, err := json.Marshal(req)
2090
+ if err != nil {
2091
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
2092
+ }
2093
+
2094
+ request, err := http.NewRequest(http.MethodPut, serverUrl, bytes.NewBuffer(reqBytes))
2095
+ if err != nil {
2096
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %v", err)}
2097
+ }
2098
+ request.Header.Set("Content-Type", "application/json")
2099
+
2100
+ resp, err := authenticatedFastClient.Do(request)
2101
+ if err != nil {
2102
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2103
+ }
2104
+ defer resp.Body.Close()
2105
+
2106
+ if resp.StatusCode >= 400 {
2107
+ errorBody, _ := io.ReadAll(resp.Body)
2108
+ apiErr := HandleApiError(resp, errorBody)
2109
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2110
+ if authRefreshed {
2111
+ return a.UpdateDefaultPlanConfig(req)
2112
+ }
2113
+ return apiErr
2114
+ }
2115
+
2116
+ return nil
2117
+ }
2118
+
2119
+ func (a *Api) CreateCustomModels(input *shared.ModelsInput) *shared.ApiError {
2120
+ serverUrl := fmt.Sprintf("%s/custom_models", GetApiHost())
2121
+ body, err := json.Marshal(input)
2122
+ if err != nil {
2123
+ return &shared.ApiError{Msg: "Failed to marshal model"}
2124
+ }
2125
+
2126
+ resp, err := authenticatedFastClient.Post(serverUrl, "application/json", bytes.NewBuffer(body))
2127
+ if err != nil {
2128
+ return &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2129
+ }
2130
+ defer resp.Body.Close()
2131
+
2132
+ if resp.StatusCode >= 400 {
2133
+ errorBody, _ := io.ReadAll(resp.Body)
2134
+
2135
+ apiErr := HandleApiError(resp, errorBody)
2136
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2137
+ if authRefreshed {
2138
+ return a.CreateCustomModels(input)
2139
+ }
2140
+ return apiErr
2141
+ }
2142
+
2143
+ return nil
2144
+ }
2145
+
2146
+ func (a *Api) ListCustomModels() ([]*shared.CustomModel, *shared.ApiError) {
2147
+ serverUrl := fmt.Sprintf("%s/custom_models", GetApiHost())
2148
+ resp, err := authenticatedFastClient.Get(serverUrl)
2149
+ if err != nil {
2150
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2151
+ }
2152
+ defer resp.Body.Close()
2153
+
2154
+ if resp.StatusCode >= 400 {
2155
+ errorBody, _ := io.ReadAll(resp.Body)
2156
+
2157
+ apiErr := HandleApiError(resp, errorBody)
2158
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2159
+ if authRefreshed {
2160
+ return a.ListCustomModels()
2161
+ }
2162
+ return nil, apiErr
2163
+ }
2164
+
2165
+ var models []*shared.CustomModel
2166
+ err = json.NewDecoder(resp.Body).Decode(&models)
2167
+ if err != nil {
2168
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2169
+ }
2170
+
2171
+ return models, nil
2172
+ }
2173
+
2174
+ func (a *Api) ListCustomProviders() ([]*shared.CustomProvider, *shared.ApiError) {
2175
+ serverUrl := fmt.Sprintf("%s/custom_providers", GetApiHost())
2176
+ resp, err := authenticatedFastClient.Get(serverUrl)
2177
+ if err != nil {
2178
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2179
+ }
2180
+ defer resp.Body.Close()
2181
+
2182
+ if resp.StatusCode >= 400 {
2183
+ errorBody, _ := io.ReadAll(resp.Body)
2184
+ apiErr := HandleApiError(resp, errorBody)
2185
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2186
+ if authRefreshed {
2187
+ return a.ListCustomProviders()
2188
+ }
2189
+ return nil, apiErr
2190
+ }
2191
+
2192
+ var providers []*shared.CustomProvider
2193
+ err = json.NewDecoder(resp.Body).Decode(&providers)
2194
+ if err != nil {
2195
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2196
+ }
2197
+
2198
+ return providers, nil
2199
+ }
2200
+
2201
+ func (a *Api) ListModelPacks() ([]*shared.ModelPack, *shared.ApiError) {
2202
+ serverUrl := fmt.Sprintf("%s/model_sets", GetApiHost())
2203
+
2204
+ resp, err := authenticatedFastClient.Get(serverUrl)
2205
+ if err != nil {
2206
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2207
+ }
2208
+ defer resp.Body.Close()
2209
+
2210
+ if resp.StatusCode >= 400 {
2211
+ errorBody, _ := io.ReadAll(resp.Body)
2212
+
2213
+ apiErr := HandleApiError(resp, errorBody)
2214
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2215
+ if authRefreshed {
2216
+ return a.ListModelPacks()
2217
+ }
2218
+ return nil, apiErr
2219
+ }
2220
+
2221
+ var sets []*shared.ModelPack
2222
+ err = json.NewDecoder(resp.Body).Decode(&sets)
2223
+ if err != nil {
2224
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2225
+ }
2226
+
2227
+ return sets, nil
2228
+
2229
+ }
2230
+
2231
+ func (a *Api) GetCreditsTransactions(pageSize, pageNum int, req shared.CreditsLogRequest) (*shared.CreditsLogResponse, *shared.ApiError) {
2232
+ serverUrl := fmt.Sprintf("%s/billing/credits_transactions?size=%d&page=%d", GetApiHost(), pageSize, pageNum)
2233
+
2234
+ reqBytes, err := json.Marshal(req)
2235
+ if err != nil {
2236
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
2237
+ }
2238
+
2239
+ resp, err := authenticatedFastClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
2240
+ if err != nil {
2241
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2242
+ }
2243
+ defer resp.Body.Close()
2244
+
2245
+ if resp.StatusCode >= 400 {
2246
+ errorBody, _ := io.ReadAll(resp.Body)
2247
+
2248
+ apiErr := HandleApiError(resp, errorBody)
2249
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2250
+ if authRefreshed {
2251
+ return a.GetCreditsTransactions(pageSize, pageNum, req)
2252
+ }
2253
+ return nil, apiErr
2254
+ }
2255
+
2256
+ var res *shared.CreditsLogResponse
2257
+ err = json.NewDecoder(resp.Body).Decode(&res)
2258
+ if err != nil {
2259
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2260
+ }
2261
+
2262
+ return res, nil
2263
+ }
2264
+
2265
+ func (a *Api) GetCreditsSummary(req shared.CreditsLogRequest) (*shared.CreditsSummaryResponse, *shared.ApiError) {
2266
+ serverUrl := fmt.Sprintf("%s/billing/credits_summary", GetApiHost())
2267
+
2268
+ reqBytes, err := json.Marshal(req)
2269
+ if err != nil {
2270
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
2271
+ }
2272
+
2273
+ resp, err := authenticatedFastClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
2274
+ if err != nil {
2275
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2276
+ }
2277
+ defer resp.Body.Close()
2278
+
2279
+ if resp.StatusCode >= 400 {
2280
+ errorBody, _ := io.ReadAll(resp.Body)
2281
+ apiErr := HandleApiError(resp, errorBody)
2282
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2283
+ if authRefreshed {
2284
+ return a.GetCreditsSummary(req)
2285
+ }
2286
+ return nil, apiErr
2287
+ }
2288
+
2289
+ var res *shared.CreditsSummaryResponse
2290
+ err = json.NewDecoder(resp.Body).Decode(&res)
2291
+ if err != nil {
2292
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2293
+ }
2294
+
2295
+ return res, nil
2296
+ }
2297
+
2298
+ func (a *Api) GetBalance() (decimal.Decimal, *shared.ApiError) {
2299
+ serverUrl := fmt.Sprintf("%s/billing/balance", GetApiHost())
2300
+
2301
+ resp, err := authenticatedFastClient.Get(serverUrl)
2302
+ if err != nil {
2303
+ return decimal.Zero, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2304
+ }
2305
+ defer resp.Body.Close()
2306
+
2307
+ if resp.StatusCode >= 400 {
2308
+ errorBody, _ := io.ReadAll(resp.Body)
2309
+ apiErr := HandleApiError(resp, errorBody)
2310
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2311
+ if authRefreshed {
2312
+ return a.GetBalance()
2313
+ }
2314
+ return decimal.Zero, apiErr
2315
+ }
2316
+
2317
+ var res *shared.GetBalanceResponse
2318
+ err = json.NewDecoder(resp.Body).Decode(&res)
2319
+ if err != nil {
2320
+ return decimal.Zero, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2321
+ }
2322
+
2323
+ return res.Balance, nil
2324
+ }
2325
+
2326
+ func (a *Api) GetFileMap(req shared.GetFileMapRequest) (*shared.GetFileMapResponse, *shared.ApiError) {
2327
+ serverUrl := fmt.Sprintf("%s/file_map", GetApiHost())
2328
+ reqBytes, err := json.Marshal(req)
2329
+ if err != nil {
2330
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
2331
+ }
2332
+
2333
+ resp, err := authenticatedSlowClient.Post(serverUrl, "application/json", bytes.NewBuffer(reqBytes))
2334
+ if err != nil {
2335
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2336
+ }
2337
+ defer resp.Body.Close()
2338
+
2339
+ if resp.StatusCode >= 400 {
2340
+ errorBody, _ := io.ReadAll(resp.Body)
2341
+ apiErr := HandleApiError(resp, errorBody)
2342
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2343
+ if authRefreshed {
2344
+ return a.GetFileMap(req)
2345
+ }
2346
+ return nil, apiErr
2347
+ }
2348
+
2349
+ var respBody shared.GetFileMapResponse
2350
+ err = json.NewDecoder(resp.Body).Decode(&respBody)
2351
+ if err != nil {
2352
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2353
+ }
2354
+
2355
+ return &respBody, nil
2356
+ }
2357
+
2358
+ func (a *Api) GetContextBody(planId, branch, contextId string) (*shared.GetContextBodyResponse, *shared.ApiError) {
2359
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/context/%s/body", GetApiHost(), planId, branch, contextId)
2360
+
2361
+ resp, err := authenticatedFastClient.Get(serverUrl)
2362
+ if err != nil {
2363
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2364
+ }
2365
+ defer resp.Body.Close()
2366
+
2367
+ if resp.StatusCode >= 400 {
2368
+ errorBody, _ := io.ReadAll(resp.Body)
2369
+ apiErr := HandleApiError(resp, errorBody)
2370
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2371
+ if authRefreshed {
2372
+ return a.GetContextBody(planId, branch, contextId)
2373
+ }
2374
+ return nil, apiErr
2375
+ }
2376
+
2377
+ var respBody shared.GetContextBodyResponse
2378
+ err = json.NewDecoder(resp.Body).Decode(&respBody)
2379
+ if err != nil {
2380
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2381
+ }
2382
+
2383
+ return &respBody, nil
2384
+ }
2385
+
2386
+ func (a *Api) AutoLoadContext(ctx context.Context, planId, branch string, req shared.LoadContextRequest) (*shared.LoadContextResponse, *shared.ApiError) {
2387
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/auto_load_context", GetApiHost(), planId, branch)
2388
+ reqBytes, err := json.Marshal(req)
2389
+ if err != nil {
2390
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error marshalling request: %v", err)}
2391
+ }
2392
+
2393
+ // Create a new request with context
2394
+ httpReq, err := http.NewRequestWithContext(ctx, "POST", serverUrl, bytes.NewBuffer(reqBytes))
2395
+ if err != nil {
2396
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error creating request: %v", err)}
2397
+ }
2398
+
2399
+ // Set the content type header
2400
+ httpReq.Header.Set("Content-Type", "application/json")
2401
+
2402
+ // Use the slow client since we may be uploading relatively large files
2403
+ resp, err := authenticatedSlowClient.Do(httpReq)
2404
+ if err != nil {
2405
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2406
+ }
2407
+ defer resp.Body.Close()
2408
+
2409
+ if resp.StatusCode >= 400 {
2410
+ errorBody, _ := io.ReadAll(resp.Body)
2411
+ apiErr := HandleApiError(resp, errorBody)
2412
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2413
+ if authRefreshed {
2414
+ return a.LoadContext(planId, branch, req)
2415
+ }
2416
+ return nil, apiErr
2417
+ }
2418
+
2419
+ var loadContextResponse shared.LoadContextResponse
2420
+ err = json.NewDecoder(resp.Body).Decode(&loadContextResponse)
2421
+ if err != nil {
2422
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2423
+ }
2424
+
2425
+ return &loadContextResponse, nil
2426
+ }
2427
+
2428
+ func (a *Api) GetBuildStatus(planId, branch string) (*shared.GetBuildStatusResponse, *shared.ApiError) {
2429
+ serverUrl := fmt.Sprintf("%s/plans/%s/%s/build_status", GetApiHost(), planId, branch)
2430
+
2431
+ resp, err := authenticatedFastClient.Get(serverUrl)
2432
+ if err != nil {
2433
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error sending request: %v", err)}
2434
+ }
2435
+ defer resp.Body.Close()
2436
+
2437
+ if resp.StatusCode >= 400 {
2438
+ errorBody, _ := io.ReadAll(resp.Body)
2439
+ apiErr := HandleApiError(resp, errorBody)
2440
+ authRefreshed, apiErr := refreshAuthIfNeeded(apiErr)
2441
+ if authRefreshed {
2442
+ return a.GetBuildStatus(planId, branch)
2443
+ }
2444
+ return nil, apiErr
2445
+ }
2446
+
2447
+ var respBody shared.GetBuildStatusResponse
2448
+ err = json.NewDecoder(resp.Body).Decode(&respBody)
2449
+ if err != nil {
2450
+ return nil, &shared.ApiError{Type: shared.ApiErrorTypeOther, Msg: fmt.Sprintf("error decoding response: %v", err)}
2451
+ }
2452
+
2453
+ return &respBody, nil
2454
+ }
app/cli/api/stream.go ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "bufio"
5
+ "bytes"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "log"
10
+ "plandex-cli/types"
11
+ "time"
12
+
13
+ shared "plandex-shared"
14
+ )
15
+
16
+ // 3 heartbeat misses = timeout
17
+ const HeartbeatTimeout = 16 * time.Second
18
+
19
+ func connectPlanRespStream(body io.ReadCloser, onStream types.OnStreamPlan) {
20
+ reader := bufio.NewReader(body)
21
+ timer := time.NewTimer(HeartbeatTimeout)
22
+ defer timer.Stop()
23
+
24
+ go func() {
25
+ for {
26
+ select {
27
+ case <-timer.C:
28
+ log.Println("Connection to plan stream timed out due to missing heartbeats")
29
+ onStream(types.OnStreamPlanParams{Msg: nil, Err: fmt.Errorf("connection to plan stream timed out due to missing heartbeats")})
30
+ body.Close()
31
+ return
32
+ default:
33
+ }
34
+
35
+ s, err := readUntilSeparator(reader, shared.STREAM_MESSAGE_SEPARATOR)
36
+ if err != nil {
37
+ log.Println("Error reading line:", err)
38
+ onStream(types.OnStreamPlanParams{Msg: nil, Err: err})
39
+ body.Close()
40
+ return
41
+ }
42
+
43
+ timer.Reset(HeartbeatTimeout)
44
+
45
+ // ignore heartbeats
46
+ if s == string(shared.StreamMessageHeartbeat) {
47
+ continue
48
+ }
49
+
50
+ var msg shared.StreamMessage
51
+ err = json.Unmarshal([]byte(s), &msg)
52
+ if err != nil {
53
+ log.Println("Error unmarshalling message:", err)
54
+ onStream(types.OnStreamPlanParams{Msg: nil, Err: err})
55
+ body.Close()
56
+ return
57
+ }
58
+
59
+ // log.Println("connectPlanRespStream: received message:", msg)
60
+
61
+ onStream(types.OnStreamPlanParams{Msg: &msg, Err: nil})
62
+
63
+ if msg.Type == shared.StreamMessageFinished || msg.Type == shared.StreamMessageError || msg.Type == shared.StreamMessageAborted {
64
+ body.Close()
65
+ return
66
+ }
67
+
68
+ }
69
+ }()
70
+ }
71
+
72
+ func readUntilSeparator(reader *bufio.Reader, separator string) (string, error) {
73
+ var result []byte
74
+ sepBytes := []byte(separator)
75
+ for {
76
+ b, err := reader.ReadByte()
77
+ if err != nil {
78
+ return string(result), err
79
+ }
80
+ result = append(result, b)
81
+ if len(result) >= len(sepBytes) && bytes.HasSuffix(result, sepBytes) {
82
+ return string(result[:len(result)-len(separator)]), nil
83
+ }
84
+ }
85
+ }
app/cli/auth/account.go ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package auth
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/term"
6
+
7
+ shared "plandex-shared"
8
+
9
+ "github.com/fatih/color"
10
+ )
11
+
12
+ const AddAccountOption = "Add another account"
13
+
14
+ func SelectOrSignInOrCreate() error {
15
+ accounts, err := loadAccounts()
16
+
17
+ if err != nil {
18
+ return fmt.Errorf("error loading accounts: %v", err)
19
+ }
20
+
21
+ if len(accounts) == 0 {
22
+ err := promptSignInNewAccount()
23
+ if err != nil {
24
+ return fmt.Errorf("error signing in to new account: %v", err)
25
+ }
26
+
27
+ return nil
28
+ }
29
+
30
+ var options []string
31
+ for _, account := range accounts {
32
+ options = append(options, fmt.Sprintf("<%s> %s", account.UserName, account.Email))
33
+ }
34
+
35
+ options = append(options, AddAccountOption)
36
+
37
+ // either select from existing accounts or sign in/create account
38
+
39
+ selectedOpt, err := term.SelectFromList("Select an account:", options)
40
+
41
+ if err != nil {
42
+ return fmt.Errorf("error selecting account: %v", err)
43
+ }
44
+
45
+ if selectedOpt == AddAccountOption {
46
+ err := promptSignInNewAccount()
47
+ if err != nil {
48
+ return fmt.Errorf("error prompting for sign in to new account: %v", err)
49
+ }
50
+ return nil
51
+ }
52
+
53
+ var selected *shared.ClientAccount
54
+ for i, opt := range options {
55
+ if selectedOpt == opt {
56
+ selected = accounts[i]
57
+ break
58
+ }
59
+ }
60
+
61
+ if selected == nil {
62
+ return fmt.Errorf("error selecting account: account not found")
63
+ }
64
+
65
+ selectedAuth := *selected
66
+
67
+ setAuth(&shared.ClientAuth{
68
+ ClientAccount: selectedAuth,
69
+ })
70
+
71
+ term.StartSpinner("")
72
+ orgs, apiErr := apiClient.ListOrgs()
73
+ term.StopSpinner()
74
+
75
+ if apiErr != nil {
76
+ return fmt.Errorf("error listing orgs: %v", apiErr.Msg)
77
+ }
78
+
79
+ org, err := resolveOrgAuth(orgs, selectedAuth.IsLocalMode)
80
+
81
+ if err != nil {
82
+ return fmt.Errorf("error resolving org: %v", err)
83
+ }
84
+
85
+ err = setAuth(&shared.ClientAuth{
86
+ ClientAccount: *selected,
87
+ OrgId: org.Id,
88
+ OrgName: org.Name,
89
+ OrgIsTrial: org.IsTrial,
90
+ IntegratedModelsMode: org.IntegratedModelsMode,
91
+ })
92
+
93
+ if err != nil {
94
+ return fmt.Errorf("error setting auth: %v", err)
95
+ }
96
+
97
+ _, apiErr = apiClient.GetOrgSession()
98
+
99
+ if apiErr != nil {
100
+ return fmt.Errorf("error getting org session: %v", apiErr.Msg)
101
+ }
102
+
103
+ fmt.Printf("✅ Signed in as %s | Org: %s\n", color.New(color.Bold, term.ColorHiGreen).Sprintf("<%s> %s", Current.UserName, Current.Email), color.New(term.ColorHiCyan).Sprint(Current.OrgName))
104
+ fmt.Println()
105
+
106
+ if !term.IsRepl {
107
+ term.PrintCmds("", "")
108
+ }
109
+
110
+ return nil
111
+ }
112
+
113
+ func SignInWithCode(code, host string) error {
114
+ term.StartSpinner("")
115
+ res, apiErr := apiClient.SignIn(shared.SignInRequest{
116
+ Pin: code,
117
+ IsSignInCode: true,
118
+ }, host)
119
+ term.StopSpinner()
120
+
121
+ if apiErr != nil {
122
+ return fmt.Errorf("error signing in: %v", apiErr.Msg)
123
+ }
124
+
125
+ return handleSignInResponse(res, host)
126
+ }
127
+
128
+ func promptInitialAuth() error {
129
+ fmt.Println("👋 Hey there!\nIt looks like this is your first time using Plandex on this computer.")
130
+
131
+ err := SelectOrSignInOrCreate()
132
+
133
+ if err != nil {
134
+ return fmt.Errorf("error selecting or signing in to account: %v", err)
135
+ }
136
+
137
+ return nil
138
+ }
139
+
140
+ const (
141
+ // SignInCloudOption = "Plandex Cloud"
142
+ SignInLocalOption = "Local mode host"
143
+ SignInOtherOption = "Another host"
144
+ )
145
+
146
+ func promptSignInNewAccount() error {
147
+ selected, err := term.SelectFromList("Use local mode or another host?", []string{SignInLocalOption, SignInOtherOption})
148
+
149
+ if err != nil {
150
+ return fmt.Errorf("error selecting sign in option: %v", err)
151
+ }
152
+
153
+ var host string
154
+ var email string
155
+
156
+ if selected == SignInLocalOption {
157
+ host, err = term.GetRequiredUserStringInputWithDefault("Host:", "http://localhost:8099")
158
+ } else {
159
+ host, err = term.GetRequiredUserStringInput("Host:")
160
+ }
161
+
162
+ if err != nil {
163
+ return fmt.Errorf("error prompting host: %v", err)
164
+ }
165
+
166
+ if selected == SignInLocalOption {
167
+ email = "local-admin@plandex.ai"
168
+ } else {
169
+ email, err = term.GetRequiredUserStringInput("Your email:")
170
+ }
171
+
172
+ if err != nil {
173
+ return fmt.Errorf("error prompting email: %v", err)
174
+ }
175
+
176
+ res, err := verifyEmail(email, host)
177
+
178
+ if err != nil {
179
+ return fmt.Errorf("error verifying email: %v", err)
180
+ }
181
+
182
+ if res.hasAccount {
183
+ err := signIn(email, res.pin, host)
184
+ if err != nil {
185
+ return fmt.Errorf("error signing in: %v", err)
186
+ }
187
+ } else {
188
+ err := createAccount(email, res.pin, host, res.isLocalMode)
189
+ if err != nil {
190
+ return fmt.Errorf("error creating account: %v", err)
191
+ }
192
+ }
193
+
194
+ if !term.IsRepl {
195
+ term.PrintCmds("", "")
196
+ }
197
+
198
+ return nil
199
+ }
200
+
201
+ type verifyEmailRes struct {
202
+ hasAccount bool
203
+ isLocalMode bool
204
+ pin string
205
+ }
206
+
207
+ func verifyEmail(email, host string) (*verifyEmailRes, error) {
208
+ term.StartSpinner("")
209
+ res, apiErr := apiClient.CreateEmailVerification(email, host, "")
210
+ term.StopSpinner()
211
+
212
+ if apiErr != nil {
213
+ return nil, fmt.Errorf("error creating email verification: %v", apiErr.Msg)
214
+ }
215
+
216
+ if res.IsLocalMode {
217
+ return &verifyEmailRes{
218
+ hasAccount: res.HasAccount,
219
+ isLocalMode: true,
220
+ pin: "",
221
+ }, nil
222
+ }
223
+
224
+ fmt.Println("✉️ You'll now receive a 6 character pin by email. It will be valid for 5 minutes.")
225
+
226
+ pin, err := term.GetUserPasswordInput("Please enter your pin:")
227
+
228
+ if err != nil {
229
+ return nil, fmt.Errorf("error prompting pin: %v", err)
230
+ }
231
+
232
+ return &verifyEmailRes{
233
+ hasAccount: res.HasAccount,
234
+ isLocalMode: false,
235
+ pin: pin,
236
+ }, nil
237
+ }
238
+
239
+ func signIn(email, pin, host string) error {
240
+ term.StartSpinner("")
241
+ res, apiErr := apiClient.SignIn(shared.SignInRequest{
242
+ Email: email,
243
+ Pin: pin,
244
+ }, host)
245
+ term.StopSpinner()
246
+
247
+ if apiErr != nil {
248
+ return fmt.Errorf("error signing in: %v", apiErr.Msg)
249
+ }
250
+
251
+ return handleSignInResponse(res, host)
252
+ }
253
+
254
+ func handleSignInResponse(res *shared.SessionResponse, host string) error {
255
+ isLocalMode := host != "" && res.IsLocalMode
256
+
257
+ err := setAuth(&shared.ClientAuth{
258
+ ClientAccount: shared.ClientAccount{
259
+ Email: res.Email,
260
+ UserId: res.UserId,
261
+ UserName: res.UserName,
262
+ Token: res.Token,
263
+ IsTrial: false,
264
+ IsCloud: host == "",
265
+ Host: host,
266
+ IsLocalMode: isLocalMode,
267
+ },
268
+ })
269
+
270
+ if err != nil {
271
+ return fmt.Errorf("error setting auth: %v", err)
272
+ }
273
+
274
+ org, err := resolveOrgAuth(res.Orgs, isLocalMode)
275
+
276
+ if err != nil {
277
+ return fmt.Errorf("error resolving org: %v", err)
278
+ }
279
+
280
+ Current.OrgId = org.Id
281
+ Current.OrgName = org.Name
282
+ Current.IntegratedModelsMode = org.IntegratedModelsMode
283
+
284
+ err = writeCurrentAuth()
285
+
286
+ if err != nil {
287
+ return fmt.Errorf("error writing auth: %v", err)
288
+ }
289
+
290
+ fmt.Printf("✅ Signed in as %s | Org: %s\n", color.New(color.Bold, term.ColorHiGreen).Sprintf("<%s> %s", Current.UserName, Current.Email), color.New(term.ColorHiCyan).Sprint(Current.OrgName))
291
+ fmt.Println()
292
+
293
+ return nil
294
+ }
295
+
296
+ func createAccount(email, pin, host string, isLocalMode bool) error {
297
+ var name string
298
+
299
+ if isLocalMode {
300
+ name = "Local Admin"
301
+ } else {
302
+ var err error
303
+ name, err = term.GetUserStringInput("Your name:")
304
+
305
+ if err != nil {
306
+ return fmt.Errorf("error prompting name: %v", err)
307
+ }
308
+ }
309
+
310
+ term.StartSpinner("🌟 Creating account...")
311
+ res, apiErr := apiClient.CreateAccount(shared.CreateAccountRequest{
312
+ Email: email,
313
+ UserName: name,
314
+ Pin: pin,
315
+ }, host)
316
+ term.StopSpinner()
317
+
318
+ if apiErr != nil {
319
+ return fmt.Errorf("error creating account: %v", apiErr.Msg)
320
+ }
321
+
322
+ if res.IsLocalMode {
323
+ isLocalMode = true
324
+ }
325
+
326
+ err := setAuth(&shared.ClientAuth{
327
+ ClientAccount: shared.ClientAccount{
328
+ Email: res.Email,
329
+ UserId: res.UserId,
330
+ UserName: res.UserName,
331
+ Token: res.Token,
332
+ IsTrial: false,
333
+ IsCloud: host == "",
334
+ Host: host,
335
+ IsLocalMode: isLocalMode,
336
+ },
337
+ })
338
+
339
+ if err != nil {
340
+ return fmt.Errorf("error setting auth: %v", err)
341
+ }
342
+
343
+ org, err := resolveOrgAuth(res.Orgs, isLocalMode)
344
+
345
+ if err != nil {
346
+ return fmt.Errorf("error resolving org: %v", err)
347
+ }
348
+
349
+ if org == nil {
350
+ return fmt.Errorf("no org selected")
351
+ }
352
+
353
+ Current.OrgId = org.Id
354
+ Current.OrgName = org.Name
355
+ Current.IntegratedModelsMode = org.IntegratedModelsMode
356
+
357
+ err = writeCurrentAuth()
358
+
359
+ if err != nil {
360
+ return fmt.Errorf("error writing auth: %v", err)
361
+ }
362
+
363
+ fmt.Printf("✅ Signed in as %s | Org: %s\n", color.New(color.Bold, term.ColorHiGreen).Sprintf("<%s> %s", Current.UserName, Current.Email), color.New(term.ColorHiCyan).Sprint(Current.OrgName))
364
+ fmt.Println()
365
+
366
+ return nil
367
+ }
app/cli/auth/api.go ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package auth
2
+
3
+ import (
4
+ "encoding/base64"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "plandex-cli/types"
9
+ "plandex-cli/version"
10
+
11
+ shared "plandex-shared"
12
+ )
13
+
14
+ var apiClient types.ApiClient
15
+
16
+ func SetApiClient(client types.ApiClient) {
17
+ apiClient = client
18
+ }
19
+
20
+ func SetAuthHeader(req *http.Request) error {
21
+ if Current == nil {
22
+ return fmt.Errorf("error setting auth header: auth not loaded")
23
+ }
24
+ hash := Current.ToHash()
25
+
26
+ authHeader := shared.AuthHeader{
27
+ Token: Current.Token,
28
+ OrgId: Current.OrgId,
29
+ Hash: hash,
30
+ }
31
+
32
+ bytes, err := json.Marshal(authHeader)
33
+
34
+ if err != nil {
35
+ return fmt.Errorf("error marshalling auth header: %v", err)
36
+ }
37
+
38
+ // base64 encode
39
+ token := base64.URLEncoding.EncodeToString(bytes)
40
+
41
+ req.Header.Set("Authorization", "Bearer "+token)
42
+
43
+ return nil
44
+ }
45
+
46
+ func SetVersionHeader(req *http.Request) {
47
+ req.Header.Set("X-Client-Version", version.Version)
48
+ }
app/cli/auth/auth.go ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package auth
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "plandex-cli/fs"
8
+ "plandex-cli/term"
9
+
10
+ shared "plandex-shared"
11
+ )
12
+
13
+ var openUnauthenticatedCloudURL func(msg, path string)
14
+ var openAuthenticatedURL func(msg, path string)
15
+
16
+ func SetOpenUnauthenticatedCloudURLFn(fn func(msg, path string)) {
17
+ openUnauthenticatedCloudURL = fn
18
+ }
19
+
20
+ func SetOpenAuthenticatedURLFn(fn func(msg, path string)) {
21
+ openAuthenticatedURL = fn
22
+ }
23
+
24
+ func MustResolveAuthWithOrg() {
25
+ MustResolveAuth(true)
26
+ }
27
+
28
+ func MustResolveAuth(requireOrg bool) {
29
+ if apiClient == nil {
30
+ term.OutputErrorAndExit("error resolving auth: api client not set")
31
+ }
32
+
33
+ // load HomeAuthPath file into ClientAuth struct
34
+ bytes, err := os.ReadFile(fs.HomeAuthPath)
35
+
36
+ if err != nil {
37
+ if os.IsNotExist(err) {
38
+ err = promptInitialAuth()
39
+
40
+ if err != nil {
41
+ term.OutputErrorAndExit("error resolving auth: %v", err)
42
+ }
43
+
44
+ return
45
+ } else {
46
+ term.OutputErrorAndExit("error reading auth.json: %v", err)
47
+ }
48
+ }
49
+
50
+ var auth shared.ClientAuth
51
+ err = json.Unmarshal(bytes, &auth)
52
+ if err != nil {
53
+ term.OutputErrorAndExit("error unmarshalling auth.json: %v", err)
54
+ }
55
+
56
+ Current = &auth
57
+
58
+ if requireOrg && Current.OrgId == "" {
59
+ term.StartSpinner("")
60
+ orgs, apiErr := apiClient.ListOrgs()
61
+ term.StopSpinner()
62
+
63
+ if apiErr != nil {
64
+ term.OutputErrorAndExit("Error listing orgs: %v", apiErr.Msg)
65
+ }
66
+
67
+ org, err := resolveOrgAuth(orgs, Current.IsLocalMode)
68
+
69
+ if err != nil {
70
+ term.OutputErrorAndExit("Error resolving org: %v", err)
71
+ }
72
+
73
+ if org.Id == "" {
74
+ // still no org--exit now
75
+ term.OutputErrorAndExit("No org")
76
+ }
77
+
78
+ Current.OrgId = org.Id
79
+ Current.OrgName = org.Name
80
+ Current.IntegratedModelsMode = org.IntegratedModelsMode
81
+
82
+ err = writeCurrentAuth()
83
+
84
+ if err != nil {
85
+ term.OutputErrorAndExit("Error writing auth: %v", err)
86
+ }
87
+ }
88
+ }
89
+
90
+ func RefreshInvalidToken() error {
91
+ if Current == nil {
92
+ return fmt.Errorf("error refreshing token: auth not loaded")
93
+ }
94
+ res, err := verifyEmail(Current.Email, Current.Host)
95
+
96
+ if err != nil {
97
+ return fmt.Errorf("error verifying email: %v", err)
98
+ }
99
+
100
+ if res.hasAccount {
101
+ return signIn(Current.Email, res.pin, Current.Host)
102
+ } else {
103
+ host := Current.Host
104
+ if host == "" {
105
+ host = "Plandex Cloud"
106
+ }
107
+
108
+ term.OutputErrorAndExit("Account %s not found on %s", Current.Email, host)
109
+ }
110
+
111
+ return nil
112
+ }
113
+
114
+ func RefreshAuth() error {
115
+ if Current == nil {
116
+ return fmt.Errorf("error refreshing auth: auth not loaded")
117
+ }
118
+
119
+ org, apiErr := apiClient.GetOrgSession()
120
+
121
+ if apiErr != nil {
122
+ return fmt.Errorf("error getting org session: %v", apiErr.Msg)
123
+ }
124
+
125
+ Current.OrgName = org.Name
126
+ Current.OrgIsTrial = org.IsTrial
127
+ Current.IntegratedModelsMode = org.IntegratedModelsMode
128
+
129
+ err := writeCurrentAuth()
130
+
131
+ if err != nil {
132
+ return fmt.Errorf("error writing auth: %v", err)
133
+ }
134
+
135
+ return nil
136
+ }
app/cli/auth/org.go ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package auth
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/term"
6
+ "strings"
7
+
8
+ shared "plandex-shared"
9
+ )
10
+
11
+ func resolveOrgAuth(orgs []*shared.Org, isLocalMode bool) (*shared.Org, error) {
12
+ var org *shared.Org
13
+ var err error
14
+
15
+ if len(orgs) == 0 {
16
+ if isLocalMode {
17
+ org, err = createOrg(isLocalMode)
18
+ } else {
19
+ org, err = promptNoOrgs()
20
+ }
21
+
22
+ if err != nil {
23
+ return nil, fmt.Errorf("error prompting no orgs: %v", err)
24
+ }
25
+
26
+ } else if len(orgs) == 1 {
27
+ org = orgs[0]
28
+ } else {
29
+ org, err = selectOrg(orgs, isLocalMode)
30
+
31
+ if err != nil {
32
+ return nil, fmt.Errorf("error selecting org: %v", err)
33
+ }
34
+ }
35
+
36
+ return org, nil
37
+ }
38
+
39
+ func promptNoOrgs() (*shared.Org, error) {
40
+ fmt.Println("🧐 You don't have access to any orgs yet.\n\nTo join an existing org, ask an admin to either invite you directly or give your whole email domain access.\n\nOtherwise, you can go ahead and create a new org.")
41
+
42
+ shouldCreate, err := term.ConfirmYesNo("Create a new org now?")
43
+
44
+ if err != nil {
45
+ return nil, fmt.Errorf("error prompting create org: %v", err)
46
+ }
47
+
48
+ if shouldCreate {
49
+ return createOrg(false)
50
+ }
51
+
52
+ return nil, nil
53
+ }
54
+
55
+ func createOrg(isLocalMode bool) (*shared.Org, error) {
56
+ var err error
57
+ var name string
58
+ var autoAddDomainUsers bool
59
+
60
+ if isLocalMode {
61
+ name = "Local Org"
62
+ } else {
63
+ name, err = term.GetRequiredUserStringInput("Org name:")
64
+ }
65
+ if err != nil {
66
+ return nil, fmt.Errorf("error prompting org name: %v", err)
67
+ }
68
+
69
+ if !isLocalMode {
70
+ autoAddDomainUsers, err = promptAutoAddUsersIfValid(Current.Email)
71
+ if err != nil {
72
+ return nil, fmt.Errorf("error prompting auto add domain users: %v", err)
73
+ }
74
+ }
75
+
76
+ term.StartSpinner("")
77
+ res, apiErr := apiClient.CreateOrg(shared.CreateOrgRequest{
78
+ Name: name,
79
+ AutoAddDomainUsers: autoAddDomainUsers,
80
+ })
81
+ term.StopSpinner()
82
+
83
+ if apiErr != nil {
84
+ return nil, fmt.Errorf("error creating org: %v", apiErr.Msg)
85
+ }
86
+
87
+ return &shared.Org{Id: res.Id, Name: name}, nil
88
+ }
89
+
90
+ func promptAutoAddUsersIfValid(email string) (bool, error) {
91
+ userDomain := strings.Split(email, "@")[1]
92
+ var autoAddDomainUsers bool
93
+ var err error
94
+ if !shared.IsEmailServiceDomain(userDomain) {
95
+ fmt.Println("With domain auto-join, you can allow any user with an email ending in @"+userDomain, "to auto-join this org.")
96
+ autoAddDomainUsers, err = term.ConfirmYesNo(fmt.Sprintf("Enable auto-join for %s?", userDomain))
97
+
98
+ if err != nil {
99
+ return false, err
100
+ }
101
+ }
102
+ return autoAddDomainUsers, nil
103
+ }
104
+
105
+ const CreateOrgOption = "Create a new org"
106
+
107
+ func selectOrg(orgs []*shared.Org, isLocalMode bool) (*shared.Org, error) {
108
+ var options []string
109
+ for _, org := range orgs {
110
+ options = append(options, org.Name)
111
+ }
112
+ options = append(options, CreateOrgOption)
113
+
114
+ selected, err := term.SelectFromList("Select an org:", options)
115
+
116
+ if err != nil {
117
+ return nil, fmt.Errorf("error selecting org: %v", err)
118
+ }
119
+
120
+ if selected == CreateOrgOption {
121
+ return createOrg(isLocalMode)
122
+ }
123
+
124
+ var selectedOrg *shared.Org
125
+ for _, org := range orgs {
126
+ if org.Name == selected {
127
+ selectedOrg = org
128
+ break
129
+ }
130
+ }
131
+
132
+ if selectedOrg == nil {
133
+ return nil, fmt.Errorf("error selecting org: org not found")
134
+ }
135
+
136
+ return selectedOrg, nil
137
+ }
app/cli/auth/state.go ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package auth
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "plandex-cli/fs"
8
+
9
+ shared "plandex-shared"
10
+ )
11
+
12
+ var Current *shared.ClientAuth
13
+
14
+ func loadAccounts() ([]*shared.ClientAccount, error) {
15
+ bytes, err := os.ReadFile(fs.HomeAccountsPath)
16
+
17
+ if err != nil {
18
+ if os.IsNotExist(err) {
19
+ // no accounts
20
+ return []*shared.ClientAccount{}, nil
21
+ } else {
22
+ return nil, fmt.Errorf("error reading accounts.json: %v", err)
23
+ }
24
+ }
25
+
26
+ var accounts []*shared.ClientAccount
27
+ err = json.Unmarshal(bytes, &accounts)
28
+
29
+ if err != nil {
30
+ return nil, fmt.Errorf("error unmarshalling accounts.json: %v", err)
31
+ }
32
+
33
+ return accounts, nil
34
+ }
35
+
36
+ func setAuth(auth *shared.ClientAuth) error {
37
+ err := storeAccount(&auth.ClientAccount)
38
+
39
+ if err != nil {
40
+ return fmt.Errorf("error storing account: %v", err)
41
+ }
42
+
43
+ Current = auth
44
+
45
+ err = writeCurrentAuth()
46
+
47
+ if err != nil {
48
+ return fmt.Errorf("error writing auth: %v", err)
49
+ }
50
+
51
+ return nil
52
+ }
53
+
54
+ func storeAccount(toStore *shared.ClientAccount) error {
55
+ accounts, err := loadAccounts()
56
+
57
+ if err != nil {
58
+ return fmt.Errorf("error loading accounts: %v", err)
59
+ }
60
+
61
+ found := false
62
+ for i, account := range accounts {
63
+ if account.UserId == toStore.UserId {
64
+ accounts[i] = toStore
65
+ found = true
66
+ break
67
+ }
68
+ }
69
+
70
+ if !found {
71
+ accounts = append(accounts, toStore)
72
+ }
73
+
74
+ bytes, err := json.Marshal(accounts)
75
+
76
+ if err != nil {
77
+ return fmt.Errorf("error marshalling accounts: %v", err)
78
+ }
79
+
80
+ err = os.WriteFile(fs.HomeAccountsPath, bytes, os.ModePerm)
81
+
82
+ if err != nil {
83
+ return fmt.Errorf("error writing accounts: %v", err)
84
+ }
85
+
86
+ return nil
87
+ }
88
+
89
+ func writeCurrentAuth() error {
90
+ if Current == nil {
91
+ return fmt.Errorf("error writing auth: auth not loaded")
92
+ }
93
+
94
+ bytes, err := json.Marshal(Current)
95
+
96
+ if err != nil {
97
+ return fmt.Errorf("error marshalling auth: %v", err)
98
+ }
99
+
100
+ err = os.WriteFile(fs.HomeAuthPath, bytes, os.ModePerm)
101
+
102
+ if err != nil {
103
+ return fmt.Errorf("error writing auth: %v", err)
104
+ }
105
+
106
+ return nil
107
+ }
app/cli/auth/trial.go ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package auth
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/term"
6
+ "time"
7
+ )
8
+
9
+ func ConvertTrial() {
10
+ openAuthenticatedURL("Opening Plandex Cloud upgrade flow in your browser.", "/settings/billing?upgrade=1&cliUpgrade=1")
11
+
12
+ fmt.Println("\nCommand will continue automatically once you've upgraded...")
13
+ fmt.Println()
14
+ term.StartSpinner("")
15
+
16
+ startTime := time.Now()
17
+ expirationTime := startTime.Add(1 * time.Hour)
18
+
19
+ for time.Now().Before(expirationTime) {
20
+ org, apiErr := apiClient.GetOrgSession()
21
+
22
+ if apiErr != nil {
23
+ term.StopSpinner()
24
+ term.OutputErrorAndExit("error getting org session: %s", apiErr.Msg)
25
+ }
26
+
27
+ if org != nil && !org.IsTrial {
28
+ term.StopSpinner()
29
+ fmt.Println("🚀 Trial upgraded")
30
+ fmt.Println()
31
+ return
32
+ }
33
+
34
+ time.Sleep(1500 * time.Millisecond)
35
+ }
36
+
37
+ term.StopSpinner()
38
+ term.OutputErrorAndExit("Timed out waiting for upgrade. Please try again. Email support@plandex.ai if the problem persists.")
39
+ }
40
+
41
+ func startTrial() {
42
+ term.StartSpinner("")
43
+ cliTrialToken, apiErr := apiClient.CreateCliTrialSession()
44
+ term.StopSpinner()
45
+
46
+ if apiErr != nil {
47
+ term.OutputErrorAndExit("error starting trial: %s", apiErr.Msg)
48
+ }
49
+
50
+ openUnauthenticatedCloudURL(
51
+ "Opening Plandex Cloud trial flow in your browser.",
52
+ fmt.Sprintf("/start?cliTrialToken=%s", cliTrialToken),
53
+ )
54
+
55
+ fmt.Println("\nCommand will continue automatically once you've started your trial...")
56
+ fmt.Println()
57
+ term.StartSpinner("")
58
+
59
+ startTime := time.Now()
60
+ expirationTime := startTime.Add(1 * time.Hour)
61
+
62
+ for time.Now().Before(expirationTime) {
63
+ cliTrialSession, apiErr := apiClient.GetCliTrialSession(cliTrialToken)
64
+
65
+ if apiErr != nil {
66
+ term.StopSpinner()
67
+ term.OutputErrorAndExit("error getting cli trial session: %s", apiErr.Msg)
68
+ }
69
+
70
+ if cliTrialSession != nil {
71
+ // Trial session is valid, break the loop and sign in
72
+ term.StopSpinner()
73
+ fmt.Println("🚀 Trial started")
74
+ fmt.Println()
75
+ err := handleSignInResponse(cliTrialSession, "")
76
+ if err != nil {
77
+ term.OutputErrorAndExit("error signing in after trial started: %s", err)
78
+ }
79
+ return
80
+ }
81
+
82
+ time.Sleep(1500 * time.Millisecond)
83
+ }
84
+
85
+ term.StopSpinner()
86
+ term.OutputErrorAndExit("Timed out waiting for trial to start. Please try again. Email support@plandex.ai if the problem persists.")
87
+ }
app/cli/cmd/apply.go ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "plandex-cli/auth"
5
+ "plandex-cli/lib"
6
+ "plandex-cli/plan_exec"
7
+ "plandex-cli/term"
8
+ "plandex-cli/types"
9
+
10
+ "github.com/spf13/cobra"
11
+ )
12
+
13
+ var autoCommit, skipCommit, autoExec bool
14
+
15
+ func init() {
16
+ initApplyFlags(applyCmd, false)
17
+ initExecScriptFlags(applyCmd)
18
+ RootCmd.AddCommand(applyCmd)
19
+
20
+ applyCmd.Flags().BoolVar(&fullAuto, "full", false, "Apply the plan and debug in full auto mode")
21
+ }
22
+
23
+ var applyCmd = &cobra.Command{
24
+ Use: "apply",
25
+ Aliases: []string{"ap"},
26
+ Short: "Apply a plan to the project",
27
+ Run: apply,
28
+ }
29
+
30
+ func apply(cmd *cobra.Command, args []string) {
31
+ auth.MustResolveAuthWithOrg()
32
+ lib.MustResolveProject()
33
+
34
+ if fullAuto {
35
+ term.StartSpinner("")
36
+ config := lib.MustGetCurrentPlanConfig()
37
+ _, updatedConfig, printFn := resolveAutoModeSilent(config)
38
+ lib.SetCachedPlanConfig(updatedConfig)
39
+ term.StopSpinner()
40
+ printFn()
41
+ }
42
+
43
+ mustSetPlanExecFlags(cmd, true)
44
+
45
+ if lib.CurrentPlanId == "" {
46
+ term.OutputNoCurrentPlanErrorAndExit()
47
+ }
48
+
49
+ applyFlags := types.ApplyFlags{
50
+ AutoConfirm: true,
51
+ AutoCommit: autoCommit,
52
+ NoCommit: skipCommit,
53
+ AutoExec: autoExec,
54
+ NoExec: noExec,
55
+ AutoDebug: autoDebug,
56
+ }
57
+
58
+ tellFlags := types.TellFlags{
59
+ TellBg: tellBg,
60
+ TellStop: tellStop,
61
+ TellNoBuild: tellNoBuild,
62
+ AutoContext: tellAutoContext,
63
+ ExecEnabled: !noExec,
64
+ AutoApply: tellAutoApply,
65
+ }
66
+
67
+ lib.MustApplyPlan(lib.ApplyPlanParams{
68
+ PlanId: lib.CurrentPlanId,
69
+ Branch: lib.CurrentBranch,
70
+ ApplyFlags: applyFlags,
71
+ TellFlags: tellFlags,
72
+ OnExecFail: plan_exec.GetOnApplyExecFail(applyFlags, tellFlags),
73
+ })
74
+ }
app/cli/cmd/archive.go ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/api"
6
+ "plandex-cli/auth"
7
+ "plandex-cli/lib"
8
+ "plandex-cli/term"
9
+ "strconv"
10
+ "strings"
11
+
12
+ shared "plandex-shared"
13
+
14
+ "github.com/fatih/color"
15
+ "github.com/spf13/cobra"
16
+ )
17
+
18
+ var archiveCmd = &cobra.Command{
19
+ Use: "archive [name-or-index]",
20
+ Aliases: []string{"arc"},
21
+ Short: "Archive a plan",
22
+ Args: cobra.MaximumNArgs(1),
23
+ Run: archive,
24
+ }
25
+
26
+ func init() {
27
+ RootCmd.AddCommand(archiveCmd)
28
+ }
29
+
30
+ func archive(cmd *cobra.Command, args []string) {
31
+ auth.MustResolveAuthWithOrg()
32
+ lib.MustResolveProject()
33
+
34
+ var nameOrIdx string
35
+ if len(args) > 0 {
36
+ nameOrIdx = strings.TrimSpace(args[0])
37
+ }
38
+
39
+ var plan *shared.Plan
40
+
41
+ term.StartSpinner("")
42
+ plans, apiErr := api.Client.ListPlans([]string{lib.CurrentProjectId})
43
+ term.StopSpinner()
44
+
45
+ if apiErr != nil {
46
+ term.OutputErrorAndExit("Error getting plans: %v", apiErr)
47
+ }
48
+
49
+ if len(plans) == 0 {
50
+ fmt.Println("🤷‍♂️ No plans available to archive")
51
+ return
52
+ }
53
+
54
+ if nameOrIdx == "" {
55
+ opts := make([]string, len(plans))
56
+ for i, p := range plans {
57
+ opts[i] = p.Name
58
+ }
59
+
60
+ selected, err := term.SelectFromList("Select a plan to archive", opts)
61
+ if err != nil {
62
+ term.OutputErrorAndExit("Error selecting plan: %v", err)
63
+ }
64
+
65
+ for _, p := range plans {
66
+ if p.Name == selected {
67
+ plan = p
68
+ break
69
+ }
70
+ }
71
+ } else {
72
+ idx, err := strconv.Atoi(nameOrIdx)
73
+ if err == nil && idx > 0 && idx <= len(plans) {
74
+ plan = plans[idx-1]
75
+ } else {
76
+ for _, p := range plans {
77
+ if p.Name == nameOrIdx {
78
+ plan = p
79
+ break
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ if plan == nil {
86
+ term.OutputErrorAndExit("Plan not found")
87
+ }
88
+
89
+ err := api.Client.ArchivePlan(plan.Id)
90
+ if err != nil {
91
+ term.OutputErrorAndExit("Error archiving plan: %v", err)
92
+ }
93
+
94
+ fmt.Printf("✅ Plan %s archived\n", color.New(color.Bold, term.ColorHiYellow).Sprint(plan.Name))
95
+
96
+ fmt.Println()
97
+
98
+ term.PrintCmds("", "plans --archived", "unarchive")
99
+ }
app/cli/cmd/billing.go ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "plandex-cli/auth"
5
+ "plandex-cli/term"
6
+ "plandex-cli/ui"
7
+
8
+ "github.com/spf13/cobra"
9
+ )
10
+
11
+ var billingCmd = &cobra.Command{
12
+ Use: "billing",
13
+ Short: "Open the billing page in the browser",
14
+ Run: billing,
15
+ }
16
+
17
+ func init() {
18
+ RootCmd.AddCommand(billingCmd)
19
+ }
20
+
21
+ func billing(cmd *cobra.Command, args []string) {
22
+ auth.MustResolveAuthWithOrg()
23
+
24
+ if !auth.Current.IsCloud {
25
+ term.OutputErrorAndExit("This command is only available for Plandex Cloud accounts.")
26
+ }
27
+
28
+ ui.OpenAuthenticatedURL("Opening billing page in your default browser...", "/settings/billing")
29
+ }
app/cli/cmd/branches.go ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "plandex-cli/api"
7
+ "plandex-cli/auth"
8
+ "plandex-cli/format"
9
+ "plandex-cli/lib"
10
+ "plandex-cli/term"
11
+ "strconv"
12
+
13
+ "github.com/fatih/color"
14
+ "github.com/olekukonko/tablewriter"
15
+ "github.com/spf13/cobra"
16
+ )
17
+
18
+ var branchesCmd = &cobra.Command{
19
+ Use: "branches",
20
+ Aliases: []string{"br"},
21
+ Short: "List plan branches",
22
+ Run: branches,
23
+ }
24
+
25
+ func init() {
26
+ RootCmd.AddCommand(branchesCmd)
27
+ }
28
+
29
+ func branches(cmd *cobra.Command, args []string) {
30
+ auth.MustResolveAuthWithOrg()
31
+ lib.MustResolveProject()
32
+
33
+ if lib.CurrentPlanId == "" {
34
+ term.OutputNoCurrentPlanErrorAndExit()
35
+ }
36
+
37
+ term.StartSpinner("")
38
+
39
+ branches, apiErr := api.Client.ListBranches(lib.CurrentPlanId)
40
+
41
+ term.StopSpinner()
42
+
43
+ if apiErr != nil {
44
+ term.OutputErrorAndExit("Error getting branches: %v", apiErr)
45
+ return
46
+ }
47
+
48
+ table := tablewriter.NewWriter(os.Stdout)
49
+ table.SetAutoWrapText(false)
50
+ table.SetHeader([]string{"#", "Name", "Updated" /* "Created",*/, "Context", "Convo"})
51
+
52
+ for i, b := range branches {
53
+ num := strconv.Itoa(i + 1)
54
+ if b.Name == lib.CurrentBranch {
55
+ num = color.New(color.Bold, term.ColorHiGreen).Sprint(num)
56
+ }
57
+
58
+ var name string
59
+ if b.Name == lib.CurrentBranch {
60
+ name = color.New(color.Bold, term.ColorHiGreen).Sprint(b.Name) + " 👈"
61
+ } else {
62
+ name = b.Name
63
+ }
64
+
65
+ row := []string{
66
+ num,
67
+ name,
68
+ format.Time(b.UpdatedAt),
69
+ // format.Time(b.CreatedAt),
70
+ strconv.Itoa(b.ContextTokens) + " 🪙",
71
+ strconv.Itoa(b.ConvoTokens) + " 🪙",
72
+ }
73
+
74
+ var style []tablewriter.Colors
75
+ if b.Name == lib.CurrentPlanId {
76
+ style = []tablewriter.Colors{
77
+ {tablewriter.FgGreenColor, tablewriter.Bold},
78
+ }
79
+ } else {
80
+ style = []tablewriter.Colors{
81
+ {tablewriter.Bold},
82
+ }
83
+ }
84
+
85
+ table.Rich(row, style)
86
+
87
+ }
88
+ table.Render()
89
+ fmt.Println()
90
+ term.PrintCmds("", "checkout", "delete-branch")
91
+
92
+ }
app/cli/cmd/browser.go ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "log"
8
+ "os"
9
+ "os/exec"
10
+ "os/signal"
11
+ "runtime"
12
+ "syscall"
13
+ "time"
14
+
15
+ chrome_log "github.com/chromedp/cdproto/log"
16
+ chrome_runtime "github.com/chromedp/cdproto/runtime"
17
+ "github.com/chromedp/cdproto/target"
18
+ "github.com/chromedp/chromedp"
19
+ "github.com/spf13/cobra"
20
+ )
21
+
22
+ var timeoutSeconds int
23
+
24
+ // browserCmd is our cobra command
25
+ var browserCmd = &cobra.Command{
26
+ Use: "browser [urls...]",
27
+ Short: "Open browser windows with given URLs, capturing console logs and exiting on JS errors",
28
+ RunE: browser,
29
+ }
30
+
31
+ func init() {
32
+ RootCmd.AddCommand(browserCmd)
33
+ browserCmd.Flags().IntVar(&timeoutSeconds, "timeout", 10, "Timeout in seconds for browser to load")
34
+ }
35
+
36
+ // browser is the main function for our command.
37
+ func browser(cmd *cobra.Command, args []string) error {
38
+ if len(args) == 0 {
39
+ return errors.New("no URLs provided")
40
+ }
41
+
42
+ // See if we can find Chrome or Firefox in PATH:
43
+ chromePath, _ := findChrome()
44
+
45
+ if chromePath != "" {
46
+ fmt.Println("Using Chrome (not default browser, but found in PATH).")
47
+ return openChromeWithLogs(args)
48
+ }
49
+
50
+ // Fallback: open with OS default tool (open/xdg-open)
51
+ fmt.Println("No Chrome or Firefox found; falling back to OS default opener.")
52
+ return openWithOSDefault(args)
53
+ }
54
+
55
+ // findChrome returns the path to Chrome/Chromium if found, or "" if not found.
56
+ func findChrome() (string, error) {
57
+ switch runtime.GOOS {
58
+ case "darwin":
59
+ // macOS standard installation paths
60
+ macPaths := []string{
61
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
62
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
63
+ }
64
+ for _, p := range macPaths {
65
+ if _, err := os.Stat(p); err == nil {
66
+ return p, nil
67
+ }
68
+ }
69
+ case "linux", "freebsd":
70
+ // Linux/FreeBSD (usually in PATH)
71
+ candidates := []string{
72
+ "google-chrome",
73
+ "google-chrome-stable",
74
+ "chromium",
75
+ "chromium-browser",
76
+ }
77
+ for _, c := range candidates {
78
+ if path, err := exec.LookPath(c); err == nil {
79
+ return path, nil
80
+ }
81
+ }
82
+ }
83
+ return "", errors.New("Chrome/Chromium not found")
84
+ }
85
+
86
+ var pages = map[target.ID]string{}
87
+
88
+ // openChromeWithLogs uses chromedp to open each URL in a visible Chrome browser,
89
+ // logs JS console messages, and exits on the first JS error (or if user presses Ctrl+C).
90
+ func openChromeWithLogs(urls []string) error {
91
+ if len(urls) == 0 {
92
+ return errors.New("no URLs provided")
93
+ }
94
+
95
+ fmt.Println("Launching Chrome with console log capture...")
96
+ // 1) Create a cancellable context that sets up a Chrome ExecAllocator
97
+ rootCtx, cancelAllocator := chromedp.NewExecAllocator(context.Background(),
98
+ chromedp.Flag("headless", false), // Visible, not headless
99
+ chromedp.Flag("disable-gpu", false),
100
+ chromedp.Flag("no-first-run", true), // Avoid "Welcome" dialog
101
+ chromedp.Flag("no-default-browser-check", true), // Avoid default browser dialog
102
+ chromedp.Flag("disable-default-apps", true), // Prevent default apps/extensions from loading
103
+ chromedp.Flag("remote-debugging-port", "0"), // Ensures separate debug session
104
+ )
105
+ defer cancelAllocator()
106
+
107
+ // 2) Create a new browser context from the allocator
108
+ browserCtx, cancelBrowser := chromedp.NewContext(rootCtx)
109
+ defer cancelBrowser()
110
+
111
+ // Add this to get the initial target
112
+ var initialTargetID target.ID
113
+ chromedp.ListenTarget(browserCtx, func(ev interface{}) {
114
+ if e, ok := ev.(*target.EventTargetCreated); ok {
115
+ // fmt.Printf("[DEBUG] Got event: %#v\n", e)
116
+ if e.TargetInfo.Type == "page" {
117
+ initialTargetID = e.TargetInfo.TargetID
118
+ }
119
+ }
120
+ })
121
+
122
+ if err := chromedp.Run(
123
+ browserCtx,
124
+ chrome_runtime.Enable(),
125
+ chrome_log.Enable(),
126
+ ); err != nil {
127
+ log.Fatal(err)
128
+ }
129
+
130
+ // Listen for console API calls & JS exceptions
131
+ chromedp.ListenBrowser(browserCtx, func(ev interface{}) {
132
+ // fmt.Printf("[DEBUG] Got event: %#v\n", ev)
133
+ switch e := ev.(type) {
134
+
135
+ case *target.EventTargetCreated:
136
+ case *target.EventAttachedToTarget:
137
+ // This sometimes also includes target info
138
+ info := e.TargetInfo
139
+ if info.Type == "page" {
140
+ url := info.URL
141
+ if url == "about:blank" {
142
+ url = urls[0]
143
+ }
144
+ pages[info.TargetID] = url
145
+ }
146
+
147
+ case *target.EventTargetDestroyed:
148
+ // Something was destroyed
149
+ destroyedID := e.TargetID
150
+ // Check if it was a top-level page
151
+ if url, ok := pages[destroyedID]; ok {
152
+ fmt.Printf("[CHROME] Closed page: %s\n", url)
153
+ // remove from map
154
+ delete(pages, destroyedID)
155
+ // If that was your last page, do something:
156
+ if len(pages) == 0 {
157
+ cancelBrowser()
158
+ cancelAllocator()
159
+ os.Exit(0)
160
+ } else {
161
+ fmt.Printf("num pages left: %d\n", len(pages))
162
+ }
163
+ }
164
+ }
165
+ })
166
+
167
+ // Channels to handle signals & error detection
168
+ sigChan := make(chan os.Signal, 1)
169
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
170
+
171
+ errChan := make(chan bool, 1)
172
+
173
+ // 3) Open all URLs in new tabs
174
+ for i, url := range urls {
175
+ // Reuse the initial browser tab for the first URL
176
+ var ctx context.Context
177
+ if i == 0 {
178
+ ctx, _ = chromedp.NewContext(browserCtx, chromedp.WithTargetID(initialTargetID))
179
+ } else {
180
+ // Create a new tab for each additional URL
181
+ ctx, _ = chromedp.NewContext(browserCtx)
182
+ }
183
+ tabCtx, _ := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
184
+
185
+ chromedp.ListenTarget(tabCtx, func(ev interface{}) {
186
+ switch e := ev.(type) {
187
+ case *chrome_runtime.EventConsoleAPICalled:
188
+ logType := e.Type.String()
189
+ for _, arg := range e.Args {
190
+ val := arg.Value
191
+ fmt.Printf("[CHROME:%s] %v\n", logType, val)
192
+ if logType == "error" {
193
+ fmt.Println("❌ Chrome console error")
194
+ errChan <- true
195
+ }
196
+ }
197
+ case *chrome_log.EventEntryAdded:
198
+ fmt.Printf("[CHROME:Log:%s] %s\n", e.Entry.Level, e.Entry.Text)
199
+ case *chrome_runtime.EventExceptionThrown:
200
+ fmt.Printf("[CHROME:Exception] %s\n", e.ExceptionDetails.Error())
201
+ fmt.Println("❌ JavaScript exception")
202
+ errChan <- true
203
+ }
204
+ })
205
+
206
+ if err := chromedp.Run(
207
+ tabCtx,
208
+ chrome_runtime.Enable(),
209
+ chrome_log.Enable(),
210
+ chromedp.Navigate(url),
211
+ chromedp.WaitReady("body", chromedp.ByQuery),
212
+ ); err != nil {
213
+ fmt.Printf("Failed to load url %s: %v", url, err)
214
+ cancelBrowser()
215
+ cancelAllocator()
216
+ os.Exit(1)
217
+ } else {
218
+ fmt.Printf("Opened (%d/%d): %s\n", i+1, len(urls), url)
219
+ }
220
+ }
221
+
222
+ fmt.Println("Chrome is running, waiting for error or interrupt...")
223
+
224
+ // 4) Wait forever, or until an error or signal
225
+ select {
226
+ case <-sigChan:
227
+ fmt.Println("\n⚠️ Plandex browser process interrupted")
228
+ return nil
229
+ case <-rootCtx.Done():
230
+ fmt.Println("\n⚠️ Plandex browser process closed")
231
+ return nil
232
+ case <-errChan:
233
+ fmt.Println("\n❌ Plandex browser process exited due to JavaScript error")
234
+ cancelBrowser()
235
+ cancelAllocator()
236
+ os.Exit(1)
237
+ }
238
+ return nil
239
+ }
240
+
241
+ func openWithOSDefault(urls []string) error {
242
+ var openCmd string
243
+ switch runtime.GOOS {
244
+ case "darwin":
245
+ openCmd = "open"
246
+ case "linux", "freebsd":
247
+ openCmd = "xdg-open"
248
+ default:
249
+ return errors.New("unsupported OS for fallback")
250
+ }
251
+
252
+ for _, url := range urls {
253
+ cmd := exec.Command(openCmd, url)
254
+ if err := cmd.Start(); err != nil {
255
+ log.Printf("Failed to open %s with %s: %v", url, openCmd, err)
256
+ }
257
+ }
258
+ return nil
259
+ }
app/cli/cmd/build.go ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/auth"
6
+ "plandex-cli/lib"
7
+ "plandex-cli/plan_exec"
8
+ "plandex-cli/term"
9
+ "plandex-cli/types"
10
+
11
+ shared "plandex-shared"
12
+
13
+ "github.com/spf13/cobra"
14
+ )
15
+
16
+ var buildCmd = &cobra.Command{
17
+ Use: "build",
18
+ Aliases: []string{"b"},
19
+ Short: "Build pending changes",
20
+ // Long: ``,
21
+ Args: cobra.NoArgs,
22
+ Run: build,
23
+ }
24
+
25
+ func init() {
26
+ RootCmd.AddCommand(buildCmd)
27
+
28
+ initExecFlags(buildCmd, initExecFlagsParams{
29
+ omitFile: true,
30
+ omitNoBuild: true,
31
+ omitEditor: true,
32
+ omitStop: true,
33
+ omitAutoContext: true,
34
+ omitSmartContext: true,
35
+ })
36
+ }
37
+
38
+ func build(cmd *cobra.Command, args []string) {
39
+ auth.MustResolveAuthWithOrg()
40
+ lib.MustResolveProject()
41
+
42
+ mustSetPlanExecFlags(cmd, false)
43
+
44
+ didBuild, err := plan_exec.Build(plan_exec.ExecParams{
45
+ CurrentPlanId: lib.CurrentPlanId,
46
+ CurrentBranch: lib.CurrentBranch,
47
+ AuthVars: lib.MustVerifyAuthVars(auth.Current.IntegratedModelsMode),
48
+ CheckOutdatedContext: func(maybeContexts []*shared.Context, projectPaths *types.ProjectPaths) (bool, bool, error) {
49
+ auto := autoConfirm || tellAutoApply || tellAutoContext
50
+ return lib.CheckOutdatedContextWithOutput(auto, auto, maybeContexts, projectPaths)
51
+ },
52
+ }, types.BuildFlags{
53
+ BuildBg: tellBg,
54
+ AutoApply: tellAutoApply,
55
+ })
56
+
57
+ if err != nil {
58
+ term.OutputErrorAndExit("Error building plan: %v", err)
59
+ }
60
+
61
+ if !didBuild {
62
+ fmt.Println()
63
+ term.PrintCmds("", "log", "tell", "continue")
64
+ return
65
+ }
66
+
67
+ if tellBg {
68
+ fmt.Println("🏗️ Building plan in the background")
69
+ fmt.Println()
70
+ term.PrintCmds("", "ps", "connect", "stop")
71
+ } else if tellAutoApply {
72
+ applyFlags := types.ApplyFlags{
73
+ AutoConfirm: true,
74
+ AutoCommit: autoCommit,
75
+ NoCommit: !autoCommit,
76
+ NoExec: noExec,
77
+ AutoExec: autoExec,
78
+ AutoDebug: autoDebug,
79
+ }
80
+
81
+ tellFlags := types.TellFlags{
82
+ AutoContext: tellAutoContext,
83
+ ExecEnabled: !noExec,
84
+ AutoApply: tellAutoApply,
85
+ }
86
+
87
+ lib.MustApplyPlan(lib.ApplyPlanParams{
88
+ PlanId: lib.CurrentPlanId,
89
+ Branch: lib.CurrentBranch,
90
+ ApplyFlags: applyFlags,
91
+ TellFlags: tellFlags,
92
+ OnExecFail: plan_exec.GetOnApplyExecFail(applyFlags, tellFlags),
93
+ })
94
+ } else {
95
+ fmt.Println()
96
+ term.PrintCmds("", "diff", "diff --ui", "apply", "reject", "log")
97
+ }
98
+ }
app/cli/cmd/cd.go ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/api"
6
+ "plandex-cli/auth"
7
+ "plandex-cli/lib"
8
+ "plandex-cli/term"
9
+ "strconv"
10
+ "strings"
11
+ "time"
12
+
13
+ shared "plandex-shared"
14
+
15
+ "github.com/fatih/color"
16
+ "github.com/spf13/cobra"
17
+ )
18
+
19
+ func init() {
20
+ RootCmd.AddCommand(cdCmd)
21
+ }
22
+
23
+ var cdCmd = &cobra.Command{
24
+ Use: "cd [name-or-index]",
25
+ Aliases: []string{"set-plan"},
26
+ Short: "Set current plan by name or index",
27
+ Args: cobra.MaximumNArgs(1),
28
+ Run: cd,
29
+ }
30
+
31
+ func cd(cmd *cobra.Command, args []string) {
32
+ auth.MustResolveAuthWithOrg()
33
+ lib.MustResolveProject()
34
+
35
+ var nameOrIdx string
36
+ if len(args) > 0 {
37
+ nameOrIdx = strings.TrimSpace(args[0])
38
+ }
39
+
40
+ var plan *shared.Plan
41
+
42
+ term.StartSpinner("")
43
+ plans, apiErr := api.Client.ListPlans([]string{lib.CurrentProjectId})
44
+ term.StopSpinner()
45
+
46
+ if apiErr != nil {
47
+ term.OutputErrorAndExit("Error getting plans: %v", apiErr)
48
+ }
49
+
50
+ if len(plans) == 0 {
51
+ fmt.Println("🤷‍♂️ No plans")
52
+ fmt.Println()
53
+ term.PrintCmds("", "new")
54
+ return
55
+ }
56
+
57
+ if nameOrIdx == "" {
58
+
59
+ opts := make([]string, len(plans))
60
+ for i, plan := range plans {
61
+ opts[i] = plan.Name
62
+ }
63
+
64
+ selected, err := term.SelectFromList("Select a plan", opts)
65
+
66
+ if err != nil {
67
+ term.OutputErrorAndExit("Error selecting plan: %v", err)
68
+ }
69
+
70
+ for _, p := range plans {
71
+ if p.Name == selected {
72
+ plan = p
73
+ break
74
+ }
75
+ }
76
+ } else {
77
+ // see if it's an index
78
+ idx, err := strconv.Atoi(nameOrIdx)
79
+
80
+ if err == nil {
81
+ if idx > 0 && idx <= len(plans) {
82
+ plan = plans[idx-1]
83
+ } else {
84
+ term.OutputErrorAndExit("Plan index out of range")
85
+ }
86
+ } else {
87
+ for _, p := range plans {
88
+ if p.Name == nameOrIdx {
89
+ plan = p
90
+ break
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ if plan == nil {
97
+ term.OutputErrorAndExit("Plan not found")
98
+ }
99
+
100
+ err := lib.WriteCurrentPlan(plan.Id)
101
+ if err != nil {
102
+ term.OutputErrorAndExit("Error setting current plan: %v", err)
103
+ }
104
+
105
+ // reload current plan, which will also handle setting the right branch
106
+ lib.MustLoadCurrentPlan()
107
+
108
+ // fire and forget SetProjectPlan request (we don't care about the response or errors)
109
+ // this only matters for setting the current plan on a new device (i.e. when the current plan is not set)
110
+ go api.Client.SetProjectPlan(lib.CurrentProjectId, shared.SetProjectPlanRequest{PlanId: plan.Id})
111
+
112
+ // give the SetProjectPlan request some time to be sent before exiting
113
+ time.Sleep(50 * time.Millisecond)
114
+
115
+ fmt.Println("✅ Changed current plan to " + color.New(term.ColorHiGreen, color.Bold).Sprint(plan.Name))
116
+
117
+ fmt.Println()
118
+ term.PrintCmds("", "current")
119
+ }
app/cli/cmd/chat.go ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/auth"
6
+ "plandex-cli/lib"
7
+ "plandex-cli/plan_exec"
8
+ "plandex-cli/types"
9
+
10
+ shared "plandex-shared"
11
+
12
+ "github.com/spf13/cobra"
13
+ )
14
+
15
+ var chatCmd = &cobra.Command{
16
+ Use: "chat [prompt]",
17
+ Aliases: []string{"c"},
18
+ Short: "Chat without making changes",
19
+ // Long: ``,
20
+ Args: cobra.RangeArgs(0, 1),
21
+ Run: doChat,
22
+ }
23
+
24
+ func init() {
25
+ RootCmd.AddCommand(chatCmd)
26
+
27
+ initExecFlags(chatCmd, initExecFlagsParams{
28
+ omitNoBuild: true,
29
+ omitStop: true,
30
+ omitBg: true,
31
+ omitApply: true,
32
+ omitExec: true,
33
+ omitSmartContext: true,
34
+ omitSkipMenu: true,
35
+ })
36
+
37
+ }
38
+
39
+ func doChat(cmd *cobra.Command, args []string) {
40
+ auth.MustResolveAuthWithOrg()
41
+ lib.MustResolveProject()
42
+ mustSetPlanExecFlags(cmd, false)
43
+
44
+ prompt := getTellPrompt(args)
45
+
46
+ if prompt == "" {
47
+ fmt.Println("🤷‍♂️ No prompt to send")
48
+ return
49
+ }
50
+
51
+ plan_exec.TellPlan(plan_exec.ExecParams{
52
+ CurrentPlanId: lib.CurrentPlanId,
53
+ CurrentBranch: lib.CurrentBranch,
54
+ AuthVars: lib.MustVerifyAuthVars(auth.Current.IntegratedModelsMode),
55
+ CheckOutdatedContext: func(maybeContexts []*shared.Context, projectPaths *types.ProjectPaths) (bool, bool, error) {
56
+ auto := autoConfirm || tellAutoApply || tellAutoContext
57
+ return lib.CheckOutdatedContextWithOutput(auto, auto, maybeContexts, projectPaths)
58
+ },
59
+ }, prompt, types.TellFlags{
60
+ IsChatOnly: true,
61
+ AutoContext: tellAutoContext,
62
+ SkipChangesMenu: tellSkipMenu,
63
+ })
64
+ }
app/cli/cmd/checkout.go ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/api"
6
+ "plandex-cli/auth"
7
+ "plandex-cli/lib"
8
+ "plandex-cli/term"
9
+ "strconv"
10
+ "strings"
11
+
12
+ shared "plandex-shared"
13
+
14
+ "github.com/fatih/color"
15
+ "github.com/spf13/cobra"
16
+ )
17
+
18
+ const (
19
+ OptCreateNewBranch = "Create a new branch"
20
+ )
21
+
22
+ var confirmCreateBranch bool
23
+
24
+ var checkoutCmd = &cobra.Command{
25
+ Use: "checkout [name-or-index]",
26
+ Aliases: []string{"co"},
27
+ Short: "Checkout an existing plan branch or create a new one",
28
+ Run: checkout,
29
+ Args: cobra.MaximumNArgs(1),
30
+ }
31
+
32
+ func init() {
33
+ RootCmd.AddCommand(checkoutCmd)
34
+ checkoutCmd.Flags().BoolVarP(&confirmCreateBranch, "yes", "y", false, "Confirm creating a new branch")
35
+ }
36
+
37
+ func checkout(cmd *cobra.Command, args []string) {
38
+ auth.MustResolveAuthWithOrg()
39
+ lib.MustResolveProject()
40
+
41
+ if lib.CurrentPlanId == "" {
42
+ term.OutputNoCurrentPlanErrorAndExit()
43
+ }
44
+
45
+ branchName := ""
46
+ willCreate := false
47
+
48
+ var nameOrIdx string
49
+ if len(args) > 0 {
50
+ nameOrIdx = strings.TrimSpace(args[0])
51
+ }
52
+
53
+ term.StartSpinner("")
54
+ branches, apiErr := api.Client.ListBranches(lib.CurrentPlanId)
55
+ term.StopSpinner()
56
+
57
+ if apiErr != nil {
58
+ term.OutputErrorAndExit("Error getting branches: %v", apiErr)
59
+ return
60
+ }
61
+
62
+ if nameOrIdx != "" {
63
+ idx, err := strconv.Atoi(nameOrIdx)
64
+
65
+ if err == nil {
66
+ if idx > 0 && idx <= len(branches) {
67
+ branchName = branches[idx-1].Name
68
+ } else {
69
+ term.OutputErrorAndExit("Branch %d not found", idx)
70
+ }
71
+ } else {
72
+ for _, b := range branches {
73
+ if b.Name == nameOrIdx {
74
+ branchName = b.Name
75
+ break
76
+ }
77
+ }
78
+ }
79
+
80
+ if branchName == "" {
81
+ fmt.Printf("🌱 Branch %s not found\n", color.New(color.Bold, term.ColorHiCyan).Sprint(nameOrIdx))
82
+
83
+ if confirmCreateBranch {
84
+ fmt.Println("✅ --yes flag set, will create branch")
85
+ branchName = nameOrIdx
86
+ willCreate = true
87
+ } else {
88
+ res, err := term.ConfirmYesNo("Create it now?")
89
+
90
+ if err != nil {
91
+ term.OutputErrorAndExit("Error getting user input: %v", err)
92
+ }
93
+
94
+ if res {
95
+ branchName = nameOrIdx
96
+ willCreate = true
97
+ } else {
98
+ return
99
+ }
100
+ }
101
+ }
102
+
103
+ }
104
+
105
+ if nameOrIdx == "" {
106
+ opts := make([]string, len(branches))
107
+ for i, branch := range branches {
108
+ opts[i] = branch.Name
109
+ }
110
+ opts = append(opts, OptCreateNewBranch)
111
+
112
+ selected, err := term.SelectFromList("Select a branch", opts)
113
+
114
+ if err != nil {
115
+ term.OutputErrorAndExit("Error selecting branch: %v", err)
116
+ return
117
+ }
118
+
119
+ if selected == OptCreateNewBranch {
120
+ branchName, err = term.GetRequiredUserStringInput("Branch name")
121
+ if err != nil {
122
+ term.OutputErrorAndExit("Error getting branch name: %v", err)
123
+ return
124
+ }
125
+ willCreate = true
126
+ } else {
127
+ branchName = selected
128
+ }
129
+ }
130
+
131
+ if branchName == "" {
132
+ term.OutputErrorAndExit("Branch not found")
133
+ }
134
+
135
+ term.StartSpinner("")
136
+ if willCreate {
137
+ err := api.Client.CreateBranch(lib.CurrentPlanId, lib.CurrentBranch, shared.CreateBranchRequest{Name: branchName})
138
+
139
+ if err != nil {
140
+ term.OutputErrorAndExit("Error creating branch: %v", err)
141
+ return
142
+ }
143
+
144
+ // fmt.Printf("✅ Created branch %s\n", color.New(color.Bold, term.ColorHiGreen).Sprint(branchName))
145
+ }
146
+
147
+ err := lib.WriteCurrentBranch(branchName)
148
+
149
+ if err != nil {
150
+ term.OutputErrorAndExit("Error setting current branch: %v", err)
151
+ return
152
+ }
153
+
154
+ updatedModelSettings, err := lib.SaveLatestPlanModelSettingsIfNeeded()
155
+ term.StopSpinner()
156
+ if err != nil {
157
+ term.OutputErrorAndExit("Error saving model settings: %v", err)
158
+ }
159
+
160
+ term.StopSpinner()
161
+
162
+ fmt.Printf("✅ Checked out branch %s\n", color.New(color.Bold, term.ColorHiGreen).Sprint(branchName))
163
+
164
+ if updatedModelSettings {
165
+ fmt.Println()
166
+ fmt.Println("🧠 Model settings file updated → ", lib.GetPlanModelSettingsPath(lib.CurrentPlanId))
167
+ }
168
+
169
+ fmt.Println()
170
+ term.PrintCmds("", "load", "tell", "branches", "delete-branch")
171
+
172
+ }
app/cli/cmd/claude_max.go ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/auth"
6
+ "plandex-cli/lib"
7
+ "plandex-cli/term"
8
+
9
+ "github.com/fatih/color"
10
+ "github.com/spf13/cobra"
11
+ )
12
+
13
+ var connectClaudeCmd = &cobra.Command{
14
+ Use: "connect-claude",
15
+ Short: "Connect your Claude Pro or Max subscription",
16
+ Run: connectClaude,
17
+ }
18
+
19
+ var disconnectClaudeCmd = &cobra.Command{
20
+ Use: "disconnect-claude",
21
+ Short: "Disconnect your Claude Pro or Max subscription",
22
+ Run: disconnectClaude,
23
+ }
24
+
25
+ var claudeStatusCmd = &cobra.Command{
26
+ Use: "claude-status",
27
+ Short: "Check the status of your Claude Pro or Max subscription",
28
+ Run: claudeStatus,
29
+ }
30
+
31
+ func connectClaude(cmd *cobra.Command, args []string) {
32
+ auth.MustResolveAuthWithOrg()
33
+ lib.ConnectClaudeMax()
34
+ }
35
+
36
+ func disconnectClaude(cmd *cobra.Command, args []string) {
37
+ auth.MustResolveAuthWithOrg()
38
+ lib.DisconnectClaudeMax()
39
+ }
40
+
41
+ func claudeStatus(cmd *cobra.Command, args []string) {
42
+ auth.MustResolveAuthWithOrg()
43
+
44
+ creds, err := lib.GetAccountCredentials()
45
+ if err != nil {
46
+ term.OutputErrorAndExit("Error getting account credentials: %v", err)
47
+ }
48
+
49
+ orgUserConfig := lib.MustGetOrgUserConfig()
50
+
51
+ connected := creds.ClaudeMax != nil && orgUserConfig.UseClaudeSubscription
52
+
53
+ if connected {
54
+ fmt.Println("✅ Claude Pro or Max subscription is connected")
55
+
56
+ // if orgUserConfig.IsClaudeSubscriptionCooldownActive() {
57
+ if true {
58
+ fmt.Println()
59
+ color.New(term.ColorHiYellow, color.Bold).Println("⏳ You've reached your Claude Pro or Max subscription quota")
60
+ fmt.Println("The next provider with valid credentials will be used for Anthropic models until the quota resets")
61
+ fmt.Println()
62
+ }
63
+
64
+ term.PrintCmds("", "disconnect-claude")
65
+ } else {
66
+ fmt.Println("❌ No Claude Pro or Max subscription is connected")
67
+ term.PrintCmds("", "connect-claude")
68
+ }
69
+ }
70
+
71
+ func init() {
72
+ RootCmd.AddCommand(connectClaudeCmd)
73
+ RootCmd.AddCommand(disconnectClaudeCmd)
74
+ RootCmd.AddCommand(claudeStatusCmd)
75
+ }
app/cli/cmd/clear.go ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/api"
6
+ "plandex-cli/auth"
7
+ "plandex-cli/lib"
8
+ "plandex-cli/term"
9
+
10
+ shared "plandex-shared"
11
+
12
+ "github.com/spf13/cobra"
13
+ )
14
+
15
+ var clearCmd = &cobra.Command{
16
+ Use: "clear",
17
+ Short: "Clear all context",
18
+ Long: `Clear all context.`,
19
+ Run: clearAllContext,
20
+ }
21
+
22
+ func clearAllContext(cmd *cobra.Command, args []string) {
23
+ auth.MustResolveAuthWithOrg()
24
+ lib.MustResolveProject()
25
+
26
+ if lib.CurrentPlanId == "" {
27
+ term.OutputNoCurrentPlanErrorAndExit()
28
+ }
29
+
30
+ term.StartSpinner("")
31
+ contexts, err := api.Client.ListContext(lib.CurrentPlanId, lib.CurrentBranch)
32
+ term.StopSpinner()
33
+
34
+ if err != nil {
35
+ term.OutputErrorAndExit("Error retrieving context: %v", err)
36
+ }
37
+
38
+ deleteIds := map[string]bool{}
39
+
40
+ for _, context := range contexts {
41
+ deleteIds[context.Id] = true
42
+ }
43
+
44
+ if len(deleteIds) > 0 {
45
+ res, err := api.Client.DeleteContext(lib.CurrentPlanId, lib.CurrentBranch, shared.DeleteContextRequest{
46
+ Ids: deleteIds,
47
+ })
48
+
49
+ if err != nil {
50
+ term.OutputErrorAndExit("Error deleting context: %v", err)
51
+ }
52
+
53
+ fmt.Println("✅ " + res.Msg)
54
+ } else {
55
+ fmt.Println("🤷‍♂️ No context removed")
56
+ }
57
+
58
+ }
59
+
60
+ func init() {
61
+ RootCmd.AddCommand(clearCmd)
62
+ }
app/cli/cmd/config.go ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/api"
6
+ "plandex-cli/auth"
7
+ "plandex-cli/lib"
8
+ "plandex-cli/term"
9
+
10
+ "github.com/fatih/color"
11
+ "github.com/spf13/cobra"
12
+ )
13
+
14
+ func init() {
15
+ RootCmd.AddCommand(configCmd)
16
+ configCmd.AddCommand(defaultConfigCmd)
17
+ }
18
+
19
+ var configCmd = &cobra.Command{
20
+ Use: "config",
21
+ Short: "Show plan config",
22
+ Run: config,
23
+ }
24
+
25
+ var defaultConfigCmd = &cobra.Command{
26
+ Use: "default",
27
+ Short: "Show default config for new plans",
28
+ Run: defaultConfig,
29
+ }
30
+
31
+ func config(cmd *cobra.Command, args []string) {
32
+ auth.MustResolveAuthWithOrg()
33
+ lib.MustResolveProject()
34
+
35
+ if lib.CurrentPlanId == "" {
36
+ term.OutputNoCurrentPlanErrorAndExit()
37
+ }
38
+
39
+ term.StartSpinner("")
40
+
41
+ config, apiErr := api.Client.GetPlanConfig(lib.CurrentPlanId)
42
+ if apiErr != nil {
43
+ term.StopSpinner()
44
+ term.OutputErrorAndExit("Error getting config: %v", apiErr.Msg)
45
+ return
46
+ }
47
+
48
+ term.StopSpinner()
49
+
50
+ color.New(color.Bold, term.ColorHiCyan).Println("⚙️ Plan Config")
51
+ lib.ShowPlanConfig(config, "")
52
+ fmt.Println()
53
+
54
+ term.PrintCmds("", "set-config", "config default", "set-config default")
55
+ }
56
+
57
+ func defaultConfig(cmd *cobra.Command, args []string) {
58
+ auth.MustResolveAuth(false)
59
+
60
+ term.StartSpinner("")
61
+ config, err := api.Client.GetDefaultPlanConfig()
62
+ term.StopSpinner()
63
+
64
+ if err != nil {
65
+ term.OutputErrorAndExit("Error getting default config: %v", err)
66
+ return
67
+ }
68
+
69
+ color.New(color.Bold, term.ColorHiCyan).Println("⚙️ Default Config")
70
+ lib.ShowPlanConfig(config, "")
71
+ fmt.Println()
72
+
73
+ term.PrintCmds("", "set-config default", "config", "set-config")
74
+ }
app/cli/cmd/connect.go ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "plandex-cli/api"
7
+ "plandex-cli/auth"
8
+ "plandex-cli/lib"
9
+ "plandex-cli/stream"
10
+ streamtui "plandex-cli/stream_tui"
11
+ "plandex-cli/term"
12
+
13
+ "github.com/spf13/cobra"
14
+ )
15
+
16
+ var connectCmd = &cobra.Command{
17
+ Use: "connect [stream-id-or-plan] [branch]",
18
+ Aliases: []string{"conn"},
19
+ Short: "Connect to an active stream",
20
+ // Long: ``,
21
+ Args: cobra.MaximumNArgs(2),
22
+ Run: connect,
23
+ }
24
+
25
+ func init() {
26
+ RootCmd.AddCommand(connectCmd)
27
+
28
+ }
29
+
30
+ func connect(cmd *cobra.Command, args []string) {
31
+ auth.MustResolveAuthWithOrg()
32
+ lib.MustResolveProject()
33
+
34
+ if lib.CurrentPlanId == "" {
35
+ term.OutputNoCurrentPlanErrorAndExit()
36
+ }
37
+
38
+ planId, branch, shouldContinue := lib.SelectActiveStream(args)
39
+
40
+ if !shouldContinue {
41
+ return
42
+ }
43
+
44
+ term.StartSpinner("")
45
+ apiErr := api.Client.ConnectPlan(planId, branch, stream.OnStreamPlan)
46
+ term.StopSpinner()
47
+
48
+ if apiErr != nil {
49
+ term.OutputErrorAndExit("Error connecting to stream: %v", apiErr)
50
+ }
51
+
52
+ go func() {
53
+ err := streamtui.StartStreamUI("", false, true)
54
+
55
+ if err != nil {
56
+ term.OutputErrorAndExit("Error starting stream UI", err)
57
+ }
58
+
59
+ fmt.Println()
60
+ term.PrintCmds("", "diff", "diff --ui", "apply", "reject", "log")
61
+
62
+ os.Exit(0)
63
+ }()
64
+
65
+ // Wait for the stream to finish
66
+ select {}
67
+ }
app/cli/cmd/context_show.go ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "log"
6
+ "plandex-cli/api"
7
+ "plandex-cli/auth"
8
+ "plandex-cli/lib"
9
+ "strconv"
10
+
11
+ "github.com/spf13/cobra"
12
+ )
13
+
14
+ func init() {
15
+ RootCmd.AddCommand(contextShowCmd)
16
+ }
17
+
18
+ var contextShowCmd = &cobra.Command{
19
+ Use: "show [name-or-index]",
20
+ Short: "Show the body of a context by name or list index",
21
+ Args: cobra.ExactArgs(1),
22
+ RunE: func(cmd *cobra.Command, args []string) error {
23
+ auth.MustResolveAuthWithOrg()
24
+ lib.MustResolveProject()
25
+
26
+ nameOrIndex := args[0]
27
+
28
+ // Get list of contexts first
29
+ contexts, err := api.Client.ListContext(lib.CurrentPlanId, lib.CurrentBranch)
30
+ if err != nil {
31
+ log.Printf("Error listing contexts: %v\n", err)
32
+ return fmt.Errorf("error listing contexts: %v", err)
33
+ }
34
+
35
+ var contextId string
36
+
37
+ // Try parsing as index first
38
+ if idx, err := strconv.Atoi(nameOrIndex); err == nil {
39
+ // Convert to 0-based index
40
+ idx--
41
+ if idx < 0 || idx >= len(contexts) {
42
+ return fmt.Errorf("invalid context index: %s", nameOrIndex)
43
+ }
44
+ contextId = contexts[idx].Id
45
+ } else {
46
+ // Try finding by name
47
+ found := false
48
+ for _, ctx := range contexts {
49
+ if ctx.Name == nameOrIndex || ctx.FilePath == nameOrIndex {
50
+ contextId = ctx.Id
51
+ found = true
52
+ break
53
+ }
54
+ }
55
+ if !found {
56
+ return fmt.Errorf("no context found with name: %s", nameOrIndex)
57
+ }
58
+ }
59
+
60
+ res, apiErr := api.Client.GetContextBody(lib.CurrentPlanId, lib.CurrentBranch, contextId)
61
+ if apiErr != nil {
62
+ log.Printf("Error getting context body: %v\n", apiErr)
63
+ return fmt.Errorf("error getting context body: %v", apiErr)
64
+ }
65
+
66
+ fmt.Println(res.Body)
67
+ return nil
68
+ },
69
+ }
app/cli/cmd/continue.go ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "plandex-cli/auth"
5
+ "plandex-cli/lib"
6
+ "plandex-cli/plan_exec"
7
+ "plandex-cli/types"
8
+
9
+ shared "plandex-shared"
10
+
11
+ "github.com/spf13/cobra"
12
+ )
13
+
14
+ var (
15
+ chatOnly bool
16
+ )
17
+
18
+ var continueCmd = &cobra.Command{
19
+ Use: "continue",
20
+ Aliases: []string{"c"},
21
+ Short: "Continue the plan",
22
+ Run: doContinue,
23
+ }
24
+
25
+ func init() {
26
+ RootCmd.AddCommand(continueCmd)
27
+
28
+ continueCmd.Flags().BoolVar(&chatOnly, "chat", false, "Continue in chat mode (no file changes)")
29
+
30
+ initExecFlags(continueCmd, initExecFlagsParams{
31
+ omitFile: true,
32
+ omitEditor: true,
33
+ })
34
+ }
35
+
36
+ func doContinue(cmd *cobra.Command, args []string) {
37
+ auth.MustResolveAuthWithOrg()
38
+ lib.MustResolveProject()
39
+ mustSetPlanExecFlags(cmd, false)
40
+
41
+ tellFlags := types.TellFlags{
42
+ TellBg: tellBg,
43
+ TellStop: tellStop,
44
+ TellNoBuild: tellNoBuild,
45
+ IsUserContinue: true,
46
+ ExecEnabled: !noExec,
47
+ AutoContext: tellAutoContext,
48
+ SmartContext: tellSmartContext,
49
+ AutoApply: tellAutoApply,
50
+ IsChatOnly: chatOnly,
51
+ SkipChangesMenu: tellSkipMenu,
52
+ }
53
+
54
+ plan_exec.TellPlan(plan_exec.ExecParams{
55
+ CurrentPlanId: lib.CurrentPlanId,
56
+ CurrentBranch: lib.CurrentBranch,
57
+ AuthVars: lib.MustVerifyAuthVars(auth.Current.IntegratedModelsMode),
58
+ CheckOutdatedContext: func(maybeContexts []*shared.Context, projectPaths *types.ProjectPaths) (bool, bool, error) {
59
+ auto := autoConfirm || tellAutoApply || tellAutoContext
60
+
61
+ return lib.CheckOutdatedContextWithOutput(auto, auto, maybeContexts, projectPaths)
62
+ },
63
+ }, "", tellFlags)
64
+
65
+ if tellAutoApply {
66
+ applyFlags := types.ApplyFlags{
67
+ AutoConfirm: true,
68
+ AutoCommit: autoCommit,
69
+ NoCommit: !autoCommit,
70
+ AutoExec: autoExec,
71
+ NoExec: noExec,
72
+ AutoDebug: autoDebug,
73
+ }
74
+
75
+ lib.MustApplyPlan(lib.ApplyPlanParams{
76
+ PlanId: lib.CurrentPlanId,
77
+ Branch: lib.CurrentBranch,
78
+ ApplyFlags: applyFlags,
79
+ TellFlags: tellFlags,
80
+ OnExecFail: plan_exec.GetOnApplyExecFail(applyFlags, tellFlags),
81
+ })
82
+ }
83
+ }
app/cli/cmd/convo.go ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/api"
6
+ "plandex-cli/auth"
7
+ "plandex-cli/lib"
8
+ "plandex-cli/term"
9
+ "regexp"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/fatih/color"
14
+ "github.com/spf13/cobra"
15
+ )
16
+
17
+ var plainTextOutput bool
18
+ var convoRaw bool
19
+
20
+ // convoCmd represents the convo command
21
+ var convoCmd = &cobra.Command{
22
+ Use: "convo [msg-range]",
23
+ Short: "Display complete conversation history",
24
+ Long: `Display complete conversation history. Optionally specify a message number or range of messages (e.g. '1' or '5' or '1-5' or '5-')`,
25
+ Run: convo,
26
+ }
27
+
28
+ func init() {
29
+ RootCmd.AddCommand(convoCmd)
30
+
31
+ convoCmd.Flags().BoolVarP(&plainTextOutput, "plain", "p", false, "Output conversation in plain text with no ANSI codes")
32
+
33
+ // for debugging output
34
+ convoCmd.Flags().BoolVar(&convoRaw, "raw", false, "Output conversation in raw format")
35
+ convoCmd.Flags().MarkHidden("raw")
36
+ }
37
+
38
+ const stoppedEarlyMsg = "You stopped the reply early"
39
+
40
+ func convo(cmd *cobra.Command, args []string) {
41
+ auth.MustResolveAuthWithOrg()
42
+ lib.MustResolveProject()
43
+
44
+ term.StartSpinner("")
45
+ conversation, apiErr := api.Client.ListConvo(lib.CurrentPlanId, lib.CurrentBranch)
46
+ term.StopSpinner()
47
+
48
+ if apiErr != nil {
49
+ term.OutputErrorAndExit("Error loading conversation: %v", apiErr.Msg)
50
+ }
51
+
52
+ if len(conversation) == 0 {
53
+ fmt.Println("🤷‍♂️ No conversation history")
54
+ return
55
+ }
56
+
57
+ var msgRange string
58
+ var msgRangeStart, msgRangeEnd int
59
+ if len(args) > 0 {
60
+ msgRange = args[0]
61
+ }
62
+ if msgRange != "" {
63
+ // validate either a number or a range of numbers
64
+ if strings.Contains(msgRange, "-") {
65
+ _, err := fmt.Sscanf(msgRange, "%d-%d", &msgRangeStart, &msgRangeEnd)
66
+ if err != nil {
67
+ _, err := fmt.Sscanf(msgRange, "%d-", &msgRangeStart)
68
+
69
+ if err != nil {
70
+ term.OutputErrorAndExit("Invalid message range: %s", msgRange)
71
+ }
72
+
73
+ msgRangeEnd = len(conversation)
74
+ }
75
+ } else {
76
+ _, err := fmt.Sscanf(msgRange, "%d", &msgRangeStart)
77
+ if err != nil {
78
+ term.OutputErrorAndExit("Invalid message number: %s", msgRange)
79
+ }
80
+ msgRangeEnd = msgRangeStart
81
+ }
82
+ }
83
+
84
+ var convo string
85
+ var totalTokens int
86
+ var didCut bool
87
+ for i, msg := range conversation {
88
+ if msgRangeStart > 0 && msg.Num < msgRangeStart {
89
+ didCut = true
90
+ continue
91
+ }
92
+ if msgRangeEnd > 0 && msg.Num > msgRangeEnd {
93
+ didCut = true
94
+ break
95
+ }
96
+
97
+ var author string
98
+ if msg.Role == "assistant" {
99
+ author = "🤖 Plandex"
100
+ } else if msg.Role == "user" {
101
+ author = "💬 You"
102
+ } else {
103
+ author = msg.Role
104
+ }
105
+
106
+ replyTags := msg.Flags.GetReplyTags()
107
+
108
+ // format as above but start with day of week
109
+ formattedTs := msg.CreatedAt.Local().Format("Mon Jan 2, 2006 | 3:04pm MST")
110
+
111
+ // if it's today then use 'Today' instead of the date
112
+ if msg.CreatedAt.Day() == time.Now().Day() {
113
+ formattedTs = msg.CreatedAt.Local().Format("Today | 3:04pm MST")
114
+ }
115
+
116
+ // if it's yesterday then use 'Yesterday' instead of the date
117
+ if msg.CreatedAt.Day() == time.Now().AddDate(0, 0, -1).Day() {
118
+ formattedTs = msg.CreatedAt.Local().Format("Yesterday | 3:04pm MST")
119
+ }
120
+
121
+ var header string
122
+ if len(replyTags) > 0 {
123
+ header = fmt.Sprintf("#### %d | %s | %s | %s | %d 🪙 ", i+1,
124
+ author, strings.Join(replyTags, " | "), formattedTs, msg.Tokens)
125
+ } else {
126
+ header = fmt.Sprintf("#### %d | %s | %s | %d 🪙 ", i+1,
127
+ author, formattedTs, msg.Tokens)
128
+ }
129
+
130
+ txt := msg.Message
131
+ if !convoRaw {
132
+ txt = convertCodeBlocks(msg.Message)
133
+ }
134
+
135
+ if plainTextOutput {
136
+ convo += header + "\n" + txt + "\n\n"
137
+ } else {
138
+ md, err := term.GetMarkdown(header + "\n" + txt + "\n\n")
139
+ if err != nil {
140
+ term.OutputErrorAndExit("Error creating markdown representation: %v", err)
141
+ }
142
+ convo += md
143
+ }
144
+
145
+ if !didCut && msg.Stopped {
146
+ if plainTextOutput {
147
+ convo += fmt.Sprintf(" 🛑 %s\n\n", stoppedEarlyMsg)
148
+ } else {
149
+ convo += fmt.Sprintf(" 🛑 %s\n\n", color.New(color.Bold).Sprint(stoppedEarlyMsg))
150
+ }
151
+ }
152
+
153
+ totalTokens += msg.Tokens
154
+ }
155
+
156
+ if !plainTextOutput {
157
+ convo = strings.ReplaceAll(convo, stoppedEarlyMsg, color.New(term.ColorHiRed).Sprint(stoppedEarlyMsg))
158
+ }
159
+
160
+ output :=
161
+ fmt.Sprintf("\n%s", convo)
162
+
163
+ if !plainTextOutput && !didCut {
164
+ output += term.GetDivisionLine() +
165
+ color.New(color.Bold, term.ColorHiCyan).Sprint(" Conversation size →") + fmt.Sprintf(" %d 🪙", totalTokens) + "\n\n"
166
+ }
167
+
168
+ if plainTextOutput {
169
+ fmt.Println(output)
170
+ } else {
171
+ term.PageOutput(output)
172
+
173
+ fmt.Println()
174
+ term.PrintCmds("", "convo 1", "convo 2-5", "convo --plain", "log")
175
+ }
176
+ }
177
+
178
+ var codeBlockPattern = regexp.MustCompile(`<PlandexBlock\s+lang="(.+?)".*?>([\s\S]+?)</PlandexBlock>`)
179
+
180
+ func convertCodeBlocks(msg string) string {
181
+ return codeBlockPattern.ReplaceAllStringFunc(msg, func(match string) string {
182
+ // Extract language and content from the match
183
+ submatches := codeBlockPattern.FindStringSubmatch(match)
184
+ lang := submatches[1]
185
+ content := submatches[2]
186
+
187
+ // Escape any backticks in the content
188
+ content = strings.ReplaceAll(content, "```", "\\`\\`\\`")
189
+
190
+ // Return markdown code block format
191
+ return fmt.Sprintf("```%s%s```", lang, content)
192
+ })
193
+ }
app/cli/cmd/current.go ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/api"
6
+ "plandex-cli/auth"
7
+ "plandex-cli/lib"
8
+ "plandex-cli/term"
9
+
10
+ shared "plandex-shared"
11
+
12
+ "github.com/spf13/cobra"
13
+ )
14
+
15
+ var currentCmd = &cobra.Command{
16
+ Use: "current",
17
+ Aliases: []string{"cu"},
18
+ Short: "Get the current plan",
19
+ Run: current,
20
+ }
21
+
22
+ func init() {
23
+ RootCmd.AddCommand(currentCmd)
24
+ }
25
+
26
+ func current(cmd *cobra.Command, args []string) {
27
+ auth.MustResolveAuthWithOrg()
28
+ lib.MaybeResolveProject()
29
+
30
+ if lib.CurrentPlanId == "" {
31
+ term.OutputNoCurrentPlanErrorAndExit()
32
+ }
33
+
34
+ term.StartSpinner("")
35
+ plan, err := api.Client.GetPlan(lib.CurrentPlanId)
36
+ term.StopSpinner()
37
+
38
+ if err != nil {
39
+ term.OutputErrorAndExit("Error getting plan: %v", err)
40
+ return
41
+ }
42
+
43
+ currentBranchesByPlanId, err := api.Client.GetCurrentBranchByPlanId(lib.CurrentProjectId, shared.GetCurrentBranchByPlanIdRequest{
44
+ CurrentBranchByPlanId: map[string]string{
45
+ lib.CurrentPlanId: lib.CurrentBranch,
46
+ },
47
+ })
48
+
49
+ if err != nil {
50
+ term.OutputErrorAndExit("Error getting current branches: %v", err)
51
+ }
52
+
53
+ table := lib.GetCurrentPlanTable(plan, currentBranchesByPlanId, nil)
54
+ fmt.Println(table)
55
+
56
+ term.PrintCmds("", "tell", "ls", "plans")
57
+
58
+ }
app/cli/cmd/debug.go ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "fmt"
7
+ "log"
8
+ "os"
9
+ "os/exec"
10
+ "os/signal"
11
+ "plandex-cli/auth"
12
+ "plandex-cli/lib"
13
+ "plandex-cli/plan_exec"
14
+ "plandex-cli/term"
15
+ "plandex-cli/types"
16
+ "strconv"
17
+ "strings"
18
+ "sync"
19
+ "sync/atomic"
20
+ "syscall"
21
+ "time"
22
+
23
+ shared "plandex-shared"
24
+
25
+ "github.com/fatih/color"
26
+ "github.com/spf13/cobra"
27
+ )
28
+
29
+ const DebugDefaultTries = 5
30
+
31
+ var debugCmd = &cobra.Command{
32
+ Use: "debug [tries] <cmd>",
33
+ Aliases: []string{"db"},
34
+ Short: "Debug a failing command with Plandex",
35
+ Args: cobra.MinimumNArgs(1),
36
+ Run: doDebug,
37
+ }
38
+
39
+ func init() {
40
+ RootCmd.AddCommand(debugCmd)
41
+ debugCmd.Flags().BoolVarP(&autoCommit, "commit", "c", false, "Commit changes after successful execution")
42
+ }
43
+
44
+ func doDebug(cmd *cobra.Command, args []string) {
45
+ auth.MustResolveAuthWithOrg()
46
+ lib.MustResolveProject()
47
+ mustSetPlanExecFlags(cmd, false)
48
+
49
+ if lib.CurrentPlanId == "" {
50
+ term.OutputNoCurrentPlanErrorAndExit()
51
+ }
52
+
53
+ // Parse tries and command
54
+ tries := DebugDefaultTries
55
+ cmdArgs := args
56
+
57
+ // Check if first arg is tries count
58
+ if val, err := strconv.Atoi(args[0]); err == nil {
59
+ if val <= 0 {
60
+ term.OutputErrorAndExit("Tries must be greater than 0")
61
+ }
62
+ tries = val
63
+ cmdArgs = args[1:]
64
+ if len(cmdArgs) == 0 {
65
+ term.OutputErrorAndExit("No command specified")
66
+ }
67
+ }
68
+
69
+ // Get current working directory
70
+ cwd, err := os.Getwd()
71
+ if err != nil {
72
+ term.OutputErrorAndExit("Failed to get working directory: %v", err)
73
+ }
74
+
75
+ cmdStr := strings.Join(cmdArgs, " ")
76
+
77
+ // Execute command and handle retries
78
+ for attempt := 0; attempt < tries; attempt++ {
79
+ // Use shell to handle operators like && and |
80
+ shellCmdStr := "set -euo pipefail; " + cmdStr
81
+ execCmd := exec.Command("sh", "-c", shellCmdStr)
82
+ execCmd.Dir = cwd
83
+ execCmd.Env = os.Environ()
84
+ lib.SetPlatformSpecificAttrs(execCmd)
85
+
86
+ pipe, err := execCmd.StdoutPipe()
87
+ if err != nil {
88
+ term.StopSpinner()
89
+ term.OutputErrorAndExit("Failed to create pipe: %v", err)
90
+ }
91
+ execCmd.Stderr = execCmd.Stdout
92
+
93
+ if err := execCmd.Start(); err != nil {
94
+ term.StopSpinner()
95
+ term.OutputErrorAndExit("Failed to start command: %v", err)
96
+ }
97
+
98
+ maybeDeleteCgroup := lib.MaybeIsolateCgroup(execCmd)
99
+
100
+ ctx, cancel := context.WithCancel(context.Background())
101
+ var interrupted atomic.Bool
102
+ var interruptHandled atomic.Bool
103
+ var interruptWG sync.WaitGroup
104
+
105
+ sigChan := make(chan os.Signal, 1)
106
+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT)
107
+
108
+ interruptWG.Add(1)
109
+ go func() {
110
+ defer interruptWG.Done()
111
+ for {
112
+ select {
113
+ case sig := <-sigChan:
114
+ if interruptHandled.CompareAndSwap(false, true) {
115
+ fmt.Println()
116
+ color.New(term.ColorHiYellow, color.Bold).Println("\n👉 Caught interrupt. Exiting gracefully...")
117
+ interrupted.Store(true)
118
+
119
+ var sysSig syscall.Signal
120
+
121
+ switch sig {
122
+ case os.Interrupt:
123
+ // user pressed Ctrl+C
124
+ sysSig = syscall.SIGINT
125
+ case syscall.SIGTERM:
126
+ // a polite "kill" request
127
+ sysSig = syscall.SIGTERM
128
+ case syscall.SIGHUP:
129
+ sysSig = syscall.SIGHUP
130
+ case syscall.SIGQUIT:
131
+ sysSig = syscall.SIGQUIT
132
+ default:
133
+ sysSig = syscall.SIGINT
134
+ }
135
+
136
+ if err := lib.KillProcessGroup(execCmd, sysSig); err != nil {
137
+ log.Printf("Failed to send signal %s to process group: %v", sysSig, err)
138
+ }
139
+
140
+ select {
141
+ case <-time.After(2 * time.Second):
142
+ color.New(term.ColorHiYellow, color.Bold).Println("👉 Commands didn't exit after 2 seconds. Sending SIGKILL.")
143
+ if err := lib.KillProcessGroup(execCmd, syscall.SIGKILL); err != nil {
144
+ log.Printf("Failed to send SIGKILL to process group: %v", err)
145
+ }
146
+ maybeDeleteCgroup()
147
+ case <-ctx.Done():
148
+ maybeDeleteCgroup()
149
+ return
150
+ }
151
+ }
152
+ case <-ctx.Done():
153
+ maybeDeleteCgroup()
154
+ return
155
+ }
156
+ }
157
+ }()
158
+
159
+ var outputBuilder strings.Builder
160
+ scanner := bufio.NewScanner(pipe)
161
+ go func() {
162
+ for scanner.Scan() {
163
+ line := scanner.Text()
164
+ fmt.Println(line)
165
+ outputBuilder.WriteString(line + "\n")
166
+ }
167
+ }()
168
+
169
+ waitErr := execCmd.Wait()
170
+
171
+ cancel()
172
+ interruptWG.Wait()
173
+ signal.Stop(sigChan)
174
+ close(sigChan)
175
+
176
+ if scanErr := scanner.Err(); scanErr != nil {
177
+ log.Printf("⚠️ Scanner error reading subprocess output: %v", scanErr)
178
+ }
179
+
180
+ term.StopSpinner()
181
+
182
+ outputStr := outputBuilder.String()
183
+ if outputStr == "" && waitErr != nil {
184
+ outputStr = waitErr.Error()
185
+ }
186
+
187
+ if outputStr != "" {
188
+ fmt.Println(outputStr)
189
+ }
190
+
191
+ didSucceed := waitErr == nil
192
+
193
+ if interrupted.Load() {
194
+ color.New(term.ColorHiYellow, color.Bold).Println("👉 Execution interrupted")
195
+
196
+ res, canceled, err := term.ConfirmYesNoCancel("Did the command succeed?")
197
+
198
+ if err != nil {
199
+ term.OutputErrorAndExit("Failed to get confirmation user input: %s", err)
200
+ }
201
+
202
+ didSucceed = res
203
+
204
+ if canceled {
205
+ os.Exit(0)
206
+ }
207
+ }
208
+
209
+ if didSucceed {
210
+ if attempt == 0 {
211
+ fmt.Printf("✅ Command %s succeeded on first try\n", color.New(color.Bold, term.ColorHiCyan).Sprintf(cmdStr))
212
+ } else {
213
+ lbl := "attempts"
214
+ if attempt == 1 {
215
+ lbl = "attempt"
216
+ }
217
+ fmt.Printf("✅ Command %s succeeded after %d fix %s\n", color.New(color.Bold, term.ColorHiCyan).Sprintf(cmdStr), attempt, lbl)
218
+ }
219
+ return
220
+ }
221
+
222
+ if attempt == tries-1 {
223
+ fmt.Printf("Command failed after %d tries\n", tries)
224
+ os.Exit(1)
225
+ }
226
+
227
+ // Prepare prompt for TellPlan
228
+ exitErr, ok := waitErr.(*exec.ExitError)
229
+ status := -1
230
+ if ok {
231
+ status = exitErr.ExitCode()
232
+ }
233
+
234
+ prompt := fmt.Sprintf("'%s' failed with exit status %d. Output:\n\n%s\n\n--\n\n",
235
+ strings.Join(cmdArgs, " "), status, outputStr)
236
+
237
+ tellFlags := types.TellFlags{
238
+ AutoContext: tellAutoContext,
239
+ ExecEnabled: false,
240
+ IsUserDebug: true,
241
+ }
242
+
243
+ plan_exec.TellPlan(plan_exec.ExecParams{
244
+ CurrentPlanId: lib.CurrentPlanId,
245
+ CurrentBranch: lib.CurrentBranch,
246
+ AuthVars: lib.MustVerifyAuthVars(auth.Current.IntegratedModelsMode),
247
+ CheckOutdatedContext: func(maybeContexts []*shared.Context, projectPaths *types.ProjectPaths) (bool, bool, error) {
248
+ return lib.CheckOutdatedContextWithOutput(true, true, maybeContexts, projectPaths)
249
+ },
250
+ }, prompt, tellFlags)
251
+
252
+ applyFlags := types.ApplyFlags{
253
+ AutoConfirm: true,
254
+ AutoCommit: autoCommit,
255
+ NoCommit: !autoCommit,
256
+ NoExec: false,
257
+ AutoExec: true,
258
+ }
259
+
260
+ lib.MustApplyPlan(lib.ApplyPlanParams{
261
+ PlanId: lib.CurrentPlanId,
262
+ Branch: lib.CurrentBranch,
263
+ ApplyFlags: applyFlags,
264
+ TellFlags: tellFlags,
265
+ OnExecFail: plan_exec.GetOnApplyExecFailWithCommand(applyFlags, tellFlags, cmdStr),
266
+ ExecCommand: cmdStr,
267
+ })
268
+ }
269
+ }
app/cli/cmd/delete_branch.go ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/api"
6
+ "plandex-cli/auth"
7
+ "plandex-cli/lib"
8
+ "plandex-cli/term"
9
+ "strconv"
10
+ "strings"
11
+
12
+ "github.com/fatih/color"
13
+ "github.com/spf13/cobra"
14
+ )
15
+
16
+ var deleteBranchCmd = &cobra.Command{
17
+ Use: "delete-branch",
18
+ Aliases: []string{"dlb"},
19
+ Short: "Delete a plan branch by name or index",
20
+ Run: deleteBranch,
21
+ Args: cobra.MaximumNArgs(1),
22
+ }
23
+
24
+ func init() {
25
+ RootCmd.AddCommand(deleteBranchCmd)
26
+ }
27
+
28
+ func deleteBranch(cmd *cobra.Command, args []string) {
29
+ auth.MustResolveAuthWithOrg()
30
+ lib.MustResolveProject()
31
+
32
+ if lib.CurrentPlanId == "" {
33
+ term.OutputNoCurrentPlanErrorAndExit()
34
+ }
35
+
36
+ var branch string
37
+ var nameOrIdx string
38
+
39
+ if len(args) > 0 {
40
+ nameOrIdx = strings.TrimSpace(args[0])
41
+ }
42
+
43
+ if nameOrIdx == "main" {
44
+ fmt.Println("🚨 Cannot delete main branch")
45
+ return
46
+ }
47
+
48
+ term.StartSpinner("")
49
+ branches, apiErr := api.Client.ListBranches(lib.CurrentPlanId)
50
+ term.StopSpinner()
51
+
52
+ if apiErr != nil {
53
+ term.OutputErrorAndExit("Error getting branches: %v", apiErr)
54
+ return
55
+ }
56
+
57
+ if nameOrIdx == "" {
58
+ opts := make([]string, len(branches))
59
+ for i, branch := range branches {
60
+ if branch.Name == "main" {
61
+ continue
62
+ }
63
+ opts[i] = branch.Name
64
+ }
65
+
66
+ if len(opts) == 0 {
67
+ fmt.Println("🤷‍♂️ No branches to delete")
68
+ return
69
+ }
70
+
71
+ sel, err := term.SelectFromList("Select a branch to delete", opts)
72
+
73
+ if err != nil {
74
+ term.OutputErrorAndExit("Error selecting branch: %v", err)
75
+ return
76
+ }
77
+
78
+ branch = sel
79
+ }
80
+
81
+ // see if it's an index
82
+ idx, err := strconv.Atoi(nameOrIdx)
83
+
84
+ if err == nil {
85
+ if idx > 0 && idx <= len(branches) {
86
+ branch = branches[idx-1].Name
87
+ } else {
88
+ term.OutputErrorAndExit("Branch index out of range")
89
+ }
90
+ } else {
91
+ for _, b := range branches {
92
+ if b.Name == nameOrIdx {
93
+ branch = b.Name
94
+ break
95
+ }
96
+ }
97
+
98
+ if branch == "" {
99
+ term.OutputErrorAndExit("Branch not found")
100
+ }
101
+ }
102
+
103
+ found := false
104
+ for _, b := range branches {
105
+ if b.Name == branch {
106
+ found = true
107
+ break
108
+ }
109
+ }
110
+
111
+ if !found {
112
+ fmt.Printf("🤷‍♂️ Branch %s does not exist\n", color.New(color.Bold, term.ColorHiCyan).Sprint(branch))
113
+ return
114
+ }
115
+
116
+ term.StartSpinner("")
117
+ apiErr = api.Client.DeleteBranch(lib.CurrentPlanId, branch)
118
+ term.StopSpinner()
119
+
120
+ if apiErr != nil {
121
+ term.OutputErrorAndExit("Error deleting branch: %v", apiErr)
122
+ return
123
+ }
124
+
125
+ fmt.Printf("✅ Deleted branch %s\n", color.New(color.Bold, term.ColorHiCyan).Sprint(branch))
126
+
127
+ fmt.Println()
128
+ term.PrintCmds("", "branches")
129
+ }
app/cli/cmd/delete_plan.go ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "path"
6
+ "strconv"
7
+ "strings"
8
+
9
+ "plandex-cli/api"
10
+ "plandex-cli/auth"
11
+ "plandex-cli/lib"
12
+ "plandex-cli/term"
13
+
14
+ shared "plandex-shared"
15
+
16
+ "github.com/fatih/color"
17
+ "github.com/spf13/cobra"
18
+ )
19
+
20
+ var all bool
21
+
22
+ func init() {
23
+ rmCmd.Flags().BoolVar(&all, "all", false, "Delete all plans")
24
+ RootCmd.AddCommand(rmCmd)
25
+ }
26
+
27
+ // rmCmd represents the rm command
28
+ var rmCmd = &cobra.Command{
29
+ Use: "delete-plan [name-or-index]",
30
+ Aliases: []string{"dp"},
31
+ Short: "Delete a plan by name, index, range, or pattern, or select from a list. Delete all plans with --all flag.",
32
+ Args: cobra.RangeArgs(0, 1),
33
+ Run: del,
34
+ }
35
+
36
+ func matchPlansByPattern(pattern string, plans []*shared.Plan) []*shared.Plan {
37
+ var matched []*shared.Plan
38
+ for _, plan := range plans {
39
+ if isMatched, err := path.Match(pattern, plan.Name); err == nil && isMatched {
40
+ matched = append(matched, plan)
41
+ }
42
+ }
43
+ return matched
44
+ }
45
+
46
+ func del(cmd *cobra.Command, args []string) {
47
+ auth.MustResolveAuthWithOrg()
48
+ lib.MustResolveProject()
49
+
50
+ if all {
51
+ delAll()
52
+ return
53
+ }
54
+
55
+ term.StartSpinner("")
56
+ plans, apiErr := api.Client.ListPlans([]string{lib.CurrentProjectId})
57
+ term.StopSpinner()
58
+
59
+ if apiErr != nil {
60
+ term.OutputErrorAndExit("Error getting plans: %v", apiErr)
61
+ }
62
+
63
+ if len(plans) == 0 {
64
+ fmt.Println("🤷‍♂️ No plans")
65
+ fmt.Println()
66
+ term.PrintCmds("", "new")
67
+ return
68
+ }
69
+
70
+ var plansToDelete []*shared.Plan
71
+
72
+ if len(args) == 0 {
73
+ // Interactive selection
74
+ opts := make([]string, len(plans))
75
+ for i, plan := range plans {
76
+ opts[i] = plan.Name
77
+ }
78
+
79
+ selected, err := term.SelectFromList("Select a plan:", opts)
80
+ if err != nil {
81
+ term.OutputErrorAndExit("Error selecting plan: %v", err)
82
+ }
83
+
84
+ for _, p := range plans {
85
+ if p.Name == selected {
86
+ plansToDelete = append(plansToDelete, p)
87
+ break
88
+ }
89
+ }
90
+ } else {
91
+ nameOrPattern := strings.TrimSpace(args[0])
92
+
93
+ // Check if it's a range of indices
94
+ if strings.Contains(nameOrPattern, "-") {
95
+ // Create single-element slice with the range pattern
96
+ rangeArgs := []string{nameOrPattern}
97
+ indices := parseIndices(rangeArgs)
98
+ for idx := range indices {
99
+ if idx >= 0 && idx < len(plans) {
100
+ plansToDelete = append(plansToDelete, plans[idx])
101
+ }
102
+ }
103
+ } else if strings.Contains(nameOrPattern, "*") {
104
+ // Wildcard pattern matching
105
+ plansToDelete = matchPlansByPattern(nameOrPattern, plans)
106
+ } else {
107
+ // Try as index first
108
+ idx, err := strconv.Atoi(nameOrPattern)
109
+ if err == nil {
110
+ if idx > 0 && idx <= len(plans) {
111
+ plansToDelete = append(plansToDelete, plans[idx-1])
112
+ } else {
113
+ term.OutputErrorAndExit("Plan index out of range")
114
+ }
115
+ } else {
116
+ // Try exact name match
117
+ for _, p := range plans {
118
+ if p.Name == nameOrPattern {
119
+ plansToDelete = append(plansToDelete, p)
120
+ break
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ if len(plansToDelete) == 0 {
128
+ term.OutputErrorAndExit("No matching plans found")
129
+ }
130
+
131
+ // Show confirmation with list of plans to be deleted
132
+ fmt.Printf("\nThe following %d plan(s) will be deleted:\n", len(plansToDelete))
133
+ for _, p := range plansToDelete {
134
+ fmt.Printf(" - %s\n", color.New(color.Bold, term.ColorHiCyan).Sprint(p.Name))
135
+ }
136
+ fmt.Println()
137
+
138
+ confirmed, err := term.ConfirmYesNo("Are you sure you want to delete these plans?")
139
+ if err != nil {
140
+ term.OutputErrorAndExit("Error getting confirmation: %v", err)
141
+ }
142
+ if !confirmed {
143
+ fmt.Println("Operation cancelled")
144
+ return
145
+ }
146
+
147
+ // Delete the plans
148
+ term.StartSpinner("")
149
+ for _, p := range plansToDelete {
150
+ apiErr = api.Client.DeletePlan(p.Id)
151
+ if apiErr != nil {
152
+ term.StopSpinner()
153
+ term.OutputErrorAndExit("Error deleting plan %s: %s", p.Name, apiErr.Msg)
154
+ }
155
+
156
+ if lib.CurrentPlanId == p.Id {
157
+ err := lib.ClearCurrentPlan()
158
+ if err != nil {
159
+ term.OutputErrorAndExit("Error clearing current plan: %v", err)
160
+ }
161
+ }
162
+ }
163
+ term.StopSpinner()
164
+
165
+ if len(plansToDelete) == 1 {
166
+ fmt.Printf("✅ Deleted plan '%s'\n", plansToDelete[0].Name)
167
+ } else {
168
+ fmt.Printf("✅ Deleted %d plans\n", len(plansToDelete))
169
+ }
170
+ }
171
+
172
+ func delAll() {
173
+ term.StartSpinner("")
174
+ err := api.Client.DeleteAllPlans(lib.CurrentProjectId)
175
+ term.StopSpinner()
176
+
177
+ if err != nil {
178
+ term.OutputErrorAndExit("Error deleting all plans: %v", err)
179
+ }
180
+
181
+ fmt.Println("✅ Deleted all plans")
182
+ }
app/cli/cmd/diffs.go ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "html/template"
7
+ "net"
8
+ "net/http"
9
+ "os"
10
+ "plandex-cli/api"
11
+ "plandex-cli/auth"
12
+ "plandex-cli/lib"
13
+ "plandex-cli/term"
14
+ "plandex-cli/ui"
15
+
16
+ "github.com/eiannone/keyboard"
17
+ "github.com/fatih/color"
18
+ "github.com/spf13/cobra"
19
+ )
20
+
21
+ var showDiffUi bool = true
22
+ var diffUiSideBySide = true
23
+ var diffUiLineByLine bool
24
+ var diffGit bool
25
+
26
+ var fromTellMenu bool
27
+
28
+ var diffsCmd = &cobra.Command{
29
+ Use: "diff",
30
+ Aliases: []string{"diffs"},
31
+ Short: "Review pending changes",
32
+ Run: diffs,
33
+ }
34
+
35
+ func init() {
36
+ RootCmd.AddCommand(diffsCmd)
37
+
38
+ diffsCmd.Flags().BoolVarP(&plainTextOutput, "plain", "p", false, "Output diffs in plain text with no ANSI codes")
39
+ diffsCmd.Flags().BoolVar(&showDiffUi, "ui", false, "Show diffs in a browser UI")
40
+ diffsCmd.Flags().BoolVar(&diffGit, "git", true, "Show diffs in git diff format")
41
+ diffsCmd.Flags().BoolVarP(&diffUiSideBySide, "side", "s", true, "Show diffs UI in side-by-side view")
42
+ diffsCmd.Flags().BoolVarP(&diffUiLineByLine, "line", "l", false, "Show diffs UI in line-by-line view")
43
+
44
+ diffsCmd.Flags().BoolVar(&fromTellMenu, "from-tell-menu", false, "Show diffs from the tell menu")
45
+ diffsCmd.Flags().MarkHidden("from-tell-menu")
46
+
47
+ }
48
+
49
+ func diffs(cmd *cobra.Command, args []string) {
50
+ auth.MustResolveAuthWithOrg()
51
+ lib.MaybeResolveProject()
52
+
53
+ if lib.CurrentPlanId == "" {
54
+ term.OutputNoCurrentPlanErrorAndExit()
55
+ }
56
+
57
+ term.StartSpinner("")
58
+
59
+ if showDiffUi {
60
+ diffGit = false
61
+ } else if diffGit || plainTextOutput {
62
+ showDiffUi = false
63
+ } else {
64
+ diffGit = true
65
+ }
66
+
67
+ diffs, err := api.Client.GetPlanDiffs(lib.CurrentPlanId, lib.CurrentBranch, plainTextOutput || showDiffUi)
68
+ term.StopSpinner()
69
+ if err != nil {
70
+ term.OutputErrorAndExit("Error getting plan diffs: %v", err)
71
+ return
72
+ }
73
+
74
+ if len(diffs) == 0 {
75
+ fmt.Println("🤷‍♂️ No pending changes")
76
+ return
77
+ }
78
+
79
+ if showDiffUi {
80
+ getNewListener := func() net.Listener {
81
+ outputFormat := "line-by-line"
82
+ if diffUiSideBySide {
83
+ outputFormat = "side-by-side"
84
+ } else if diffUiLineByLine {
85
+ outputFormat = "line-by-line"
86
+ }
87
+
88
+ // Properly escape the diff content for JavaScript
89
+ diffJSON, err := json.Marshal(diffs)
90
+ if err != nil {
91
+ term.OutputErrorAndExit("Error encoding diff content: %v", err)
92
+ }
93
+
94
+ // Create template data
95
+ data := struct {
96
+ DiffContent template.JS
97
+ OutputFormat string
98
+ }{
99
+ DiffContent: template.JS(diffJSON),
100
+ OutputFormat: outputFormat,
101
+ }
102
+
103
+ // Parse and execute the template
104
+ tmpl, err := template.New("diff").Parse(htmlTemplate)
105
+ if err != nil {
106
+ term.OutputErrorAndExit("Error parsing template: %v", err)
107
+ }
108
+
109
+ // Use :0 to let the OS pick an available port
110
+ listener, err := net.Listen("tcp", ":0")
111
+ if err != nil {
112
+ term.OutputErrorAndExit("Error starting server: %v", err)
113
+ }
114
+
115
+ // Get the actual port chosen
116
+ port := listener.Addr().(*net.TCPAddr).Port
117
+
118
+ // Start web server
119
+ go func() {
120
+ http.HandleFunc("/"+outputFormat, func(w http.ResponseWriter, r *http.Request) {
121
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
122
+ err := tmpl.Execute(w, data)
123
+ if err != nil {
124
+ http.Error(w, err.Error(), http.StatusInternalServerError)
125
+ }
126
+ })
127
+ http.Serve(listener, nil)
128
+ }()
129
+
130
+ ui.OpenURL("Showing "+outputFormat+" diffs in your default browser...", fmt.Sprintf("http://localhost:%d/%s", port, outputFormat))
131
+
132
+ fmt.Println()
133
+
134
+ return listener
135
+ }
136
+
137
+ listener := getNewListener()
138
+ defer listener.Close()
139
+
140
+ var relaunch bool
141
+
142
+ for {
143
+ if relaunch {
144
+ listener.Close()
145
+ listener = getNewListener()
146
+ defer listener.Close()
147
+ relaunch = false
148
+ }
149
+
150
+ if diffUiLineByLine {
151
+ fmt.Printf("%s for side-by-side view\n", color.New(color.Bold, term.ColorHiGreen).Sprintf("(s)"))
152
+ } else {
153
+ fmt.Printf("%s for line-by-line view\n", color.New(color.Bold, term.ColorHiGreen).Sprintf("(l)"))
154
+ }
155
+
156
+ fmt.Printf("%s for git diff format\n", color.New(color.Bold, term.ColorHiGreen).Sprintf("(g)"))
157
+ // fmt.Printf("%s to quit\n", color.New(color.Bold, term.ColorHiGreen).Sprintf("(q)"))
158
+
159
+ s := "to exit menu/continue"
160
+ if fromTellMenu {
161
+ s = "to go back"
162
+ }
163
+
164
+ fmt.Printf("%s %s %s %s %s",
165
+ color.New(term.ColorHiMagenta, color.Bold).Sprint("Press a hotkey,"),
166
+ color.New(color.FgHiWhite, color.Bold).Sprintf("↓"),
167
+ color.New(term.ColorHiMagenta, color.Bold).Sprintf("to select, or"),
168
+ color.New(color.FgHiWhite, color.Bold).Sprintf("enter"),
169
+ color.New(term.ColorHiMagenta, color.Bold).Sprintf("%s>", s),
170
+ )
171
+
172
+ char, key, err := term.GetUserKeyInput()
173
+ if err != nil {
174
+ term.OutputErrorAndExit("Error getting key: %v", err)
175
+ return
176
+ }
177
+ fmt.Println()
178
+
179
+ if key == keyboard.KeyArrowDown {
180
+ options := []string{}
181
+ if diffUiLineByLine {
182
+ options = append(options, "side-by-side")
183
+ } else {
184
+ options = append(options, "line-by-line")
185
+ }
186
+
187
+ options = append(options, "git diff")
188
+ options = append(options, "exit menu")
189
+
190
+ selected, err := term.SelectFromList(
191
+ "Select an action",
192
+ options,
193
+ )
194
+ if err != nil {
195
+ term.OutputErrorAndExit("Error selecting action: %v", err)
196
+ return
197
+ }
198
+
199
+ if selected == "side-by-side" {
200
+ diffUiSideBySide = true
201
+ diffUiLineByLine = false
202
+ relaunch = true
203
+ } else if selected == "line-by-line" {
204
+ diffUiSideBySide = false
205
+ diffUiLineByLine = true
206
+ relaunch = true
207
+ } else if selected == "git diff" {
208
+ showGitDiff()
209
+ } else if selected == "exit menu" {
210
+ fmt.Println()
211
+ break
212
+ }
213
+ } else if string(char) == "g" {
214
+ showGitDiff()
215
+ } else if string(char) == "s" {
216
+ diffUiSideBySide = true
217
+ diffUiLineByLine = false
218
+ relaunch = true
219
+ } else if string(char) == "l" {
220
+ diffUiSideBySide = false
221
+ diffUiLineByLine = true
222
+ relaunch = true
223
+ } else if key == 13 || key == 10 || string(char) == "q" { // Check raw key codes for Enter/Return
224
+ if term.IsRepl {
225
+ fmt.Println()
226
+ break
227
+ } else {
228
+ fmt.Println()
229
+ break
230
+ }
231
+ } else if string(char) == "\x03" { // Ctrl+C
232
+ os.Exit(0)
233
+ } else {
234
+ fmt.Println()
235
+ term.OutputSimpleError("Invalid hotkey")
236
+ fmt.Println()
237
+ }
238
+ }
239
+ } else {
240
+ if plainTextOutput {
241
+ fmt.Println(diffs)
242
+ } else {
243
+ term.PageOutput(diffs)
244
+ }
245
+ fmt.Println()
246
+ }
247
+ }
248
+
249
+ func showGitDiff() {
250
+ _, err := lib.ExecPlandexCommandWithParams([]string{"diff", "--git"}, lib.ExecPlandexCommandParams{
251
+ DisableSuggestions: true,
252
+ })
253
+ if err != nil {
254
+ term.OutputErrorAndExit("Error showing git diff: %v", err)
255
+ }
256
+ }
257
+
258
+ var htmlTemplate = `<!doctype html>
259
+ <html lang="en-us">
260
+ <head>
261
+ <meta charset="utf-8" />
262
+ <!-- Make sure to load the highlight.js CSS file before the Diff2Html CSS file -->
263
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs.min.css" />
264
+ <link
265
+ rel="stylesheet"
266
+ type="text/css"
267
+ href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css"
268
+ />
269
+ <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html-ui.min.js"></script>
270
+ </head>
271
+ <script>
272
+ // Parse the JSON-encoded diff content
273
+ const diffString = {{.DiffContent}};
274
+
275
+ document.addEventListener('DOMContentLoaded', function () {
276
+ var targetElement = document.getElementById('myDiffElement');
277
+ var configuration = {
278
+ outputFormat: '{{.OutputFormat}}'
279
+ };
280
+ var diff2htmlUi = new Diff2HtmlUI(targetElement, diffString, configuration);
281
+ diff2htmlUi.draw();
282
+ diff2htmlUi.highlightCode();
283
+ });
284
+ </script>
285
+ <body>
286
+ <div id="myDiffElement"></div>
287
+ </body>
288
+ </html>`
app/cli/cmd/invite.go ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/api"
6
+ "plandex-cli/auth"
7
+ "plandex-cli/term"
8
+
9
+ shared "plandex-shared"
10
+
11
+ "github.com/spf13/cobra"
12
+ )
13
+
14
+ var inviteCmd = &cobra.Command{
15
+ Use: "invite [email] [name] [org-role]",
16
+ Short: "Invite a new user to the org",
17
+ Run: invite,
18
+ Args: cobra.MaximumNArgs(3),
19
+ }
20
+
21
+ func init() {
22
+ RootCmd.AddCommand(inviteCmd)
23
+ }
24
+
25
+ func invite(cmd *cobra.Command, args []string) {
26
+ auth.MustResolveAuthWithOrg()
27
+
28
+ email, name, orgRoleName := "", "", ""
29
+ if len(args) >= 1 {
30
+ email = args[0]
31
+ }
32
+ if len(args) >= 2 {
33
+ name = args[1]
34
+ }
35
+ if len(args) == 3 {
36
+ orgRoleName = args[2]
37
+ }
38
+
39
+ term.StartSpinner("")
40
+ orgRoles, err := api.Client.ListOrgRoles()
41
+ term.StopSpinner()
42
+
43
+ if err != nil {
44
+ term.OutputErrorAndExit("Failed to list org roles: %v", err)
45
+ }
46
+
47
+ if email == "" {
48
+ var err error
49
+ email, err = term.GetRequiredUserStringInput("Email:")
50
+ if err != nil {
51
+ term.OutputErrorAndExit("Failed to get email: %v", err)
52
+ }
53
+ }
54
+ if name == "" {
55
+ var err error
56
+ name, err = term.GetRequiredUserStringInput("Name:")
57
+ if err != nil {
58
+ term.OutputErrorAndExit("Failed to get name: %v", err)
59
+ }
60
+ }
61
+
62
+ if orgRoleName == "" {
63
+ var orgRoleNames []string
64
+ for _, orgRole := range orgRoles {
65
+ orgRoleNames = append(orgRoleNames, orgRole.Label)
66
+ }
67
+
68
+ var err error
69
+ orgRoleName, err = term.SelectFromList("Org role:", orgRoleNames)
70
+
71
+ if err != nil {
72
+ term.OutputErrorAndExit("Failed to select org role: %v", err)
73
+ }
74
+ }
75
+
76
+ var orgRoleId string
77
+ for _, orgRole := range orgRoles {
78
+ if orgRole.Label == orgRoleName {
79
+ orgRoleId = orgRole.Id
80
+ break
81
+ }
82
+ }
83
+
84
+ if orgRoleId == "" {
85
+ term.OutputErrorAndExit("Org role '%s' not found", orgRoleName)
86
+ }
87
+
88
+ inviteRequest := shared.InviteRequest{
89
+ Email: email,
90
+ Name: name,
91
+ OrgRoleId: orgRoleId,
92
+ }
93
+
94
+ term.StartSpinner("")
95
+ apiErr := api.Client.InviteUser(inviteRequest)
96
+ term.StopSpinner()
97
+ if apiErr != nil {
98
+ term.OutputErrorAndExit("Failed to invite user: %s", apiErr.Msg)
99
+ }
100
+
101
+ fmt.Println("✅ Invite sent")
102
+ }
app/cli/cmd/load.go ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "plandex-cli/auth"
7
+ "plandex-cli/lib"
8
+ "plandex-cli/term"
9
+ "plandex-cli/types"
10
+
11
+ "github.com/sashabaranov/go-openai"
12
+ "github.com/spf13/cobra"
13
+ )
14
+
15
+ var (
16
+ recursive bool
17
+ namesOnly bool
18
+ note string
19
+ forceSkipIgnore bool
20
+ imageDetail string
21
+ defsOnly bool
22
+ )
23
+
24
+ var contextLoadCmd = &cobra.Command{
25
+ Use: "load [files-or-urls...]",
26
+ Aliases: []string{"l", "add"},
27
+ Short: "Load context from various inputs",
28
+ Long: `Load context from a file path, a directory, a URL, an image, a note, or piped data.`,
29
+ Run: contextLoad,
30
+ }
31
+
32
+ func init() {
33
+ contextLoadCmd.Flags().StringVarP(&note, "note", "n", "", "Add a note to the context")
34
+ contextLoadCmd.Flags().BoolVarP(&recursive, "recursive", "r", false, "Search directories recursively")
35
+ contextLoadCmd.Flags().BoolVar(&namesOnly, "tree", false, "Load directory tree with file names only")
36
+ contextLoadCmd.Flags().BoolVarP(&forceSkipIgnore, "force", "f", false, "Load files even when ignored by .gitignore or .plandexignore")
37
+ contextLoadCmd.Flags().StringVarP(&imageDetail, "detail", "d", "high", "Image detail level (high or low)")
38
+ contextLoadCmd.Flags().BoolVar(&defsOnly, "map", false, "Load file maps (function/method/class signatures, variable names, types, etc.)")
39
+ RootCmd.AddCommand(contextLoadCmd)
40
+ }
41
+
42
+ func contextLoad(cmd *cobra.Command, args []string) {
43
+ auth.MustResolveAuthWithOrg()
44
+ lib.MustResolveProject()
45
+
46
+ if lib.CurrentPlanId == "" {
47
+ term.OutputNoCurrentPlanErrorAndExit()
48
+ return
49
+ }
50
+
51
+ lib.MustLoadContext(args, &types.LoadContextParams{
52
+ Note: note,
53
+ Recursive: recursive,
54
+ NamesOnly: namesOnly,
55
+ ForceSkipIgnore: forceSkipIgnore,
56
+ ImageDetail: openai.ImageURLDetail(imageDetail),
57
+ DefsOnly: defsOnly,
58
+ SessionId: os.Getenv("PLANDEX_REPL_SESSION_ID"),
59
+ })
60
+
61
+ fmt.Println()
62
+ term.PrintCmds("", "ls", "tell", "debug")
63
+ }
app/cli/cmd/log.go ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "plandex-cli/api"
6
+ "plandex-cli/auth"
7
+ "plandex-cli/lib"
8
+ "plandex-cli/term"
9
+ "time"
10
+
11
+ "github.com/spf13/cobra"
12
+ )
13
+
14
+ // logCmd represents the log command
15
+ var logCmd = &cobra.Command{
16
+ Use: "log",
17
+ Aliases: []string{"history", "logs"},
18
+ Short: "Show plan history",
19
+ Long: `Show plan history`,
20
+ Args: cobra.NoArgs,
21
+ Run: runLog,
22
+ }
23
+
24
+ func init() {
25
+ // Add log command
26
+ RootCmd.AddCommand(logCmd)
27
+ }
28
+
29
+ func runLog(cmd *cobra.Command, args []string) {
30
+ auth.MustResolveAuthWithOrg()
31
+ lib.MustResolveProject()
32
+
33
+ if lib.CurrentPlanId == "" {
34
+ term.OutputNoCurrentPlanErrorAndExit()
35
+ }
36
+
37
+ term.StartSpinner("")
38
+ res, apiErr := api.Client.ListLogs(lib.CurrentPlanId, lib.CurrentBranch)
39
+ term.StopSpinner()
40
+
41
+ if apiErr != nil {
42
+ term.OutputErrorAndExit("Error getting logs: %v", apiErr)
43
+ }
44
+
45
+ withLocalTimestamps, err := convertTimestampsToLocal(res.Body)
46
+
47
+ if err != nil {
48
+ term.OutputErrorAndExit("Error converting timestamps: %v", err)
49
+ }
50
+
51
+ term.PageOutput(withLocalTimestamps)
52
+
53
+ fmt.Println()
54
+ term.PrintCmds("", "rewind", "continue", "convo", "convo 1", "convo 2-5")
55
+
56
+ }
57
+
58
+ func convertTimestampsToLocal(input string) (string, error) {
59
+ t := time.Now()
60
+ zone, _ := t.Zone()
61
+ re := lib.GitLogTimestampRegex
62
+
63
+ // Function to convert matched timestamps assuming they are in UTC to local time.
64
+ replaceFunc := func(match string) string {
65
+ t, err := time.Parse(lib.GitLogTimestampFormat, match)
66
+ if err != nil {
67
+ // In case of an error, return the original match.
68
+ return match
69
+ }
70
+
71
+ localDt := t.Local()
72
+ formattedTs := localDt.Format("Mon Jan 2, 2006 | 3:04:05pm")
73
+
74
+ if localDt.Day() == time.Now().Day() {
75
+ formattedTs = localDt.Format("Today | 3:04:05pm")
76
+ } else if localDt.Day() == time.Now().AddDate(0, 0, -1).Day() {
77
+ formattedTs = localDt.Format("Yesterday | 3:04:05pm")
78
+ }
79
+
80
+ // Convert to local time and format back to a string without the timezone to match the original format.
81
+ return formattedTs + " " + zone
82
+ }
83
+
84
+ // Find all matches and replace them.
85
+ result := re.ReplaceAllStringFunc(input, replaceFunc)
86
+
87
+ return result, nil
88
+ }
app/cli/cmd/ls.go ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "plandex-cli/api"
7
+ "plandex-cli/auth"
8
+ "plandex-cli/format"
9
+ "plandex-cli/lib"
10
+ "plandex-cli/term"
11
+ "strconv"
12
+
13
+ shared "plandex-shared"
14
+
15
+ "github.com/fatih/color"
16
+ "github.com/olekukonko/tablewriter"
17
+ "github.com/spf13/cobra"
18
+ )
19
+
20
+ var contextCmd = &cobra.Command{
21
+ Use: "ls",
22
+ Aliases: []string{"list-context"},
23
+ Short: "List everything in context",
24
+ Run: listContext,
25
+ }
26
+
27
+ func listContext(cmd *cobra.Command, args []string) {
28
+ auth.MustResolveAuthWithOrg()
29
+ lib.MustResolveProject()
30
+
31
+ term.StartSpinner("")
32
+ contexts, err := api.Client.ListContext(lib.CurrentPlanId, lib.CurrentBranch)
33
+
34
+ if err != nil {
35
+ term.OutputErrorAndExit("Error listing context: %v", err)
36
+ }
37
+
38
+ planConfig, err := api.Client.GetPlanConfig(lib.CurrentPlanId)
39
+ if err != nil {
40
+ term.OutputErrorAndExit("Error getting plan config: %v", err)
41
+ }
42
+ term.StopSpinner()
43
+
44
+ totalTokens := 0
45
+ totalPlannerTokens := 0
46
+ totalMapTokens := 0
47
+ table := tablewriter.NewWriter(os.Stdout)
48
+ table.SetHeader([]string{"#", "Name", "Type", "🪙", "Added", "Updated"})
49
+ table.SetAutoWrapText(false)
50
+
51
+ if len(contexts) == 0 {
52
+ fmt.Println("🤷‍♂️ No context")
53
+ fmt.Println()
54
+ term.PrintCmds("", "load")
55
+ return
56
+ }
57
+
58
+ for i, context := range contexts {
59
+ totalTokens += context.NumTokens
60
+
61
+ if context.ContextType == shared.ContextMapType {
62
+ totalMapTokens += context.NumTokens
63
+ } else {
64
+ totalPlannerTokens += context.NumTokens
65
+ }
66
+
67
+ t, icon := context.TypeAndIcon()
68
+
69
+ name := context.Name
70
+ if name == "" {
71
+ name = context.FilePath
72
+ }
73
+ if len(name) > 40 {
74
+ name = name[:20] + "⋯" + name[len(name)-20:]
75
+ }
76
+
77
+ row := []string{
78
+ strconv.Itoa(i + 1),
79
+ " " + icon + " " + name,
80
+ t,
81
+ strconv.Itoa(context.NumTokens), //+ " 🪙",
82
+ format.Time(context.CreatedAt),
83
+ format.Time(context.UpdatedAt),
84
+ }
85
+ table.Rich(row, []tablewriter.Colors{
86
+ {tablewriter.Bold},
87
+ {tablewriter.FgHiGreenColor, tablewriter.Bold},
88
+ })
89
+ }
90
+
91
+ table.Render()
92
+
93
+ tokensTbl := tablewriter.NewWriter(os.Stdout)
94
+ tokensTbl.SetAutoWrapText(false)
95
+
96
+ if planConfig.AutoLoadContext {
97
+ tokensTbl.Append([]string{
98
+ color.New(term.ColorHiCyan, color.Bold).Sprintf("Map tokens →") + color.New(color.Bold).Sprintf(" %d 🪙", totalMapTokens),
99
+ color.New(term.ColorHiCyan, color.Bold).Sprintf("Context tokens →") + color.New(color.Bold).Sprintf(" %d 🪙", totalPlannerTokens),
100
+ })
101
+ } else {
102
+
103
+ tokensTbl.Append([]string{color.New(term.ColorHiCyan, color.Bold).Sprintf("Total tokens →") + color.New(color.Bold).Sprintf(" %d 🪙", totalTokens)})
104
+ }
105
+ tokensTbl.Render()
106
+
107
+ fmt.Println()
108
+ term.PrintCmds("", "load", "rm", "clear")
109
+
110
+ }
111
+
112
+ func init() {
113
+ RootCmd.AddCommand(contextCmd)
114
+
115
+ }
app/cli/cmd/model_helpers.go ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "plandex-cli/lib"
7
+ "plandex-cli/term"
8
+ shared "plandex-shared"
9
+
10
+ "github.com/fatih/color"
11
+ )
12
+
13
+ func warnModelsFileLocalChanges(path, cmd string) (bool, error) {
14
+ cmdPrefix := "\\"
15
+ if !term.IsRepl {
16
+ cmdPrefix = "plandex "
17
+ }
18
+
19
+ color.New(color.Bold, term.ColorHiYellow).Println("⚠️ The models file has local changes")
20
+
21
+ fmt.Println()
22
+ fmt.Println("Path → " + path)
23
+ fmt.Println()
24
+
25
+ fmt.Println("If you continue, local changes will be dropped in favor of the latest server state")
26
+
27
+ fmt.Println()
28
+ fmt.Printf("To keep the local version instead, quit and run %s\n", color.New(color.Bold, color.BgCyan, color.FgHiWhite).
29
+ Sprintf(" %s%s --save ", cmdPrefix, cmd))
30
+ fmt.Println()
31
+ return term.ConfirmYesNo("Drop local changes and continue?")
32
+
33
+ }
34
+
35
+ type maybePromptAndOpenModelsFileResult struct {
36
+ shouldReturn bool
37
+ jsonData []byte
38
+ }
39
+
40
+ func maybePromptAndOpenModelsFile(filePath, pathArg, cmd string, defaultConfig *shared.PlanConfig, planConfig *shared.PlanConfig) maybePromptAndOpenModelsFileResult {
41
+
42
+ printManual := func() {
43
+ cmdPrefix := "\\"
44
+ if !term.IsRepl {
45
+ cmdPrefix = "plandex "
46
+ }
47
+ fmt.Println("To save changes, run " +
48
+ fmt.Sprintf(" %s ", color.New(color.Bold, color.BgCyan, color.FgHiWhite).
49
+ Sprintf(" %s%s --save%s ", cmdPrefix, cmd, pathArg)))
50
+ }
51
+
52
+ selectedEditor := lib.MaybePromptAndOpen(filePath, defaultConfig, planConfig)
53
+
54
+ if selectedEditor {
55
+ fmt.Println("📝 Opened in editor")
56
+ fmt.Println()
57
+
58
+ confirmed, err := term.ConfirmYesNo("Ready to save?")
59
+ if err != nil {
60
+ term.OutputErrorAndExit("Error confirming: %v", err)
61
+ return maybePromptAndOpenModelsFileResult{shouldReturn: true}
62
+ }
63
+
64
+ if !confirmed {
65
+ fmt.Println("🙅‍♂️ Update canceled")
66
+ fmt.Println()
67
+
68
+ printManual()
69
+ return maybePromptAndOpenModelsFileResult{shouldReturn: true}
70
+ }
71
+
72
+ // get updated file state
73
+ jsonData, err := os.ReadFile(filePath)
74
+ if err != nil {
75
+ term.OutputErrorAndExit("Error reading JSON file: %v", err)
76
+ return maybePromptAndOpenModelsFileResult{shouldReturn: true}
77
+ }
78
+
79
+ return maybePromptAndOpenModelsFileResult{shouldReturn: false, jsonData: jsonData}
80
+ } else {
81
+ // No editor available or user chose manual
82
+ fmt.Println("👨‍💻 Edit the file in your JSON editor of choice")
83
+ fmt.Println()
84
+
85
+ printManual()
86
+ return maybePromptAndOpenModelsFileResult{shouldReturn: true}
87
+ }
88
+ }
app/cli/cmd/model_packs.go ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "plandex-cli/api"
7
+ "plandex-cli/auth"
8
+ "plandex-cli/term"
9
+
10
+ shared "plandex-shared"
11
+
12
+ "github.com/fatih/color"
13
+ "github.com/olekukonko/tablewriter"
14
+ "github.com/spf13/cobra"
15
+ )
16
+
17
+ var customModelPacksOnly bool
18
+
19
+ var modelPacksCmd = &cobra.Command{
20
+ Use: "model-packs",
21
+ Short: "List all model packs",
22
+ Run: listModelPacks,
23
+ }
24
+
25
+ var createModelPackCmd = &cobra.Command{
26
+ Use: "create",
27
+ Short: "Create a model pack",
28
+ Run: customModelsNotImplemented,
29
+ }
30
+
31
+ var deleteModelPackCmd = &cobra.Command{
32
+ Use: "delete",
33
+ Aliases: []string{"rm"},
34
+ Short: "Delete a model pack by name or index",
35
+ Run: customModelsNotImplemented,
36
+ }
37
+
38
+ var updateModelPackCmd = &cobra.Command{
39
+ Use: "update",
40
+ Short: "Update a model pack by name",
41
+ Run: customModelsNotImplemented,
42
+ }
43
+
44
+ var showModelPackCmd = &cobra.Command{
45
+ Use: "show [name]",
46
+ Short: "Show a model pack by name",
47
+ Args: cobra.MaximumNArgs(1),
48
+ Run: showModelPack,
49
+ }
50
+
51
+ func init() {
52
+ RootCmd.AddCommand(modelPacksCmd)
53
+ modelPacksCmd.AddCommand(createModelPackCmd)
54
+ modelPacksCmd.AddCommand(deleteModelPackCmd)
55
+ modelPacksCmd.AddCommand(updateModelPackCmd)
56
+ modelPacksCmd.AddCommand(showModelPackCmd)
57
+ modelPacksCmd.Flags().BoolVarP(&customModelPacksOnly, "custom", "c", false, "Only show custom model packs")
58
+ modelPacksCmd.Flags().BoolVarP(&allProperties, "all", "a", false, "Show all properties")
59
+ }
60
+
61
+ func listModelPacks(cmd *cobra.Command, args []string) {
62
+ auth.MustResolveAuthWithOrg()
63
+
64
+ term.StartSpinner("")
65
+ builtInModelPacks := shared.BuiltInModelPacks
66
+
67
+ if auth.Current.IsCloud {
68
+ filtered := []*shared.ModelPack{}
69
+ for _, mp := range builtInModelPacks {
70
+ if mp.LocalProvider == "" {
71
+ filtered = append(filtered, mp)
72
+ }
73
+ }
74
+ builtInModelPacks = filtered
75
+ }
76
+
77
+ customModelPacks, err := api.Client.ListModelPacks()
78
+ term.StopSpinner()
79
+
80
+ if err != nil {
81
+ term.OutputErrorAndExit("Error fetching model packs: %v", err)
82
+ return
83
+ }
84
+
85
+ if !customModelPacksOnly {
86
+ color.New(color.Bold, term.ColorHiCyan).Println("🏠 Built-in Model Packs")
87
+ table := tablewriter.NewWriter(os.Stdout)
88
+ table.SetAutoWrapText(true)
89
+ table.SetRowLine(true)
90
+ table.SetHeader([]string{"Name", "Description"})
91
+ for _, set := range builtInModelPacks {
92
+ table.Append([]string{set.Name, set.Description})
93
+ }
94
+ table.Render()
95
+ fmt.Println()
96
+ }
97
+
98
+ if len(customModelPacks) > 0 {
99
+ color.New(color.Bold, term.ColorHiCyan).Println("🛠️ Custom Model Packs")
100
+ table := tablewriter.NewWriter(os.Stdout)
101
+ table.SetAutoWrapText(true)
102
+ table.SetRowLine(true)
103
+ table.SetHeader([]string{"#", "Name", "Description"})
104
+ for i, set := range customModelPacks {
105
+ table.Append([]string{fmt.Sprintf("%d", i+1), set.Name, set.Description})
106
+ }
107
+ table.Render()
108
+
109
+ fmt.Println()
110
+ } else if customModelPacksOnly {
111
+ fmt.Println("🤷‍♂️ No custom model packs")
112
+ fmt.Println()
113
+ }
114
+
115
+ if customModelPacksOnly && len(customModelPacks) > 0 {
116
+ term.PrintCmds("", "model-packs show", "models custom")
117
+ } else if len(customModelPacks) > 0 {
118
+ term.PrintCmds("", "model-packs --custom", "model-packs show", "models custom")
119
+ } else {
120
+ term.PrintCmds("", "model-packs show", "models custom")
121
+ }
122
+
123
+ }
124
+
125
+ func showModelPack(cmd *cobra.Command, args []string) {
126
+ auth.MustResolveAuthWithOrg()
127
+
128
+ term.StartSpinner("")
129
+ customModelPacks, apiErr := api.Client.ListModelPacks()
130
+ if apiErr != nil {
131
+ term.OutputErrorAndExit("Error fetching models: %v", apiErr)
132
+ }
133
+ customModels, err := api.Client.ListCustomModels()
134
+ if err != nil {
135
+ term.OutputErrorAndExit("Error fetching custom models: %v", err)
136
+ }
137
+ customModelsById := make(map[shared.ModelId]*shared.CustomModel)
138
+ for _, m := range customModels {
139
+ customModelsById[m.ModelId] = m
140
+ }
141
+
142
+ term.StopSpinner()
143
+
144
+ modelPacks := []*shared.ModelPack{}
145
+ modelPacks = append(modelPacks, customModelPacks...)
146
+
147
+ builtInModelPacks := shared.BuiltInModelPacks
148
+ if auth.Current.IsCloud {
149
+ filtered := []*shared.ModelPack{}
150
+ for _, mp := range builtInModelPacks {
151
+ if mp.LocalProvider == "" {
152
+ filtered = append(filtered, mp)
153
+ }
154
+ }
155
+ builtInModelPacks = filtered
156
+ }
157
+ modelPacks = append(modelPacks, builtInModelPacks...)
158
+
159
+ var name string
160
+ if len(args) > 0 {
161
+ name = args[0]
162
+ }
163
+
164
+ var modelPack *shared.ModelPack
165
+
166
+ if name == "" {
167
+ opts := make([]string, len(modelPacks))
168
+ for i, mp := range modelPacks {
169
+ opts[i] = mp.Name
170
+ }
171
+
172
+ selected, err := term.SelectFromList("Select a model pack:", opts)
173
+ if err != nil {
174
+ term.OutputErrorAndExit("Error selecting model pack: %v", err)
175
+ }
176
+
177
+ for _, mp := range modelPacks {
178
+ if mp.Name == selected {
179
+ modelPack = mp
180
+ break
181
+ }
182
+ }
183
+ } else {
184
+ for _, mp := range modelPacks {
185
+ if mp.Name == name {
186
+ modelPack = mp
187
+ break
188
+ }
189
+ }
190
+ }
191
+
192
+ if modelPack == nil {
193
+ term.OutputErrorAndExit("Model pack not found")
194
+ return
195
+ }
196
+
197
+ renderModelPack(modelPack, customModelsById, allProperties)
198
+
199
+ fmt.Println()
200
+
201
+ term.PrintCmds("", "set-model", "set-model default", "models custom")
202
+ }
203
+
204
+ // func getModelRoleConfig(customModels []*shared.CustomModel, modelRole shared.ModelRole) shared.ModelRoleConfig {
205
+ // _, modelConfig := getModelWithRoleConfig(customModels, modelRole)
206
+ // return modelConfig
207
+ // }
208
+
209
+ // func getModelWithRoleConfig(customModels []*shared.CustomModel, modelRole shared.ModelRole) (*shared.CustomModel, shared.ModelRoleConfig) {
210
+ // role := string(modelRole)
211
+
212
+ // modelId := getModelIdForRole(customModels, modelRole)
213
+
214
+ // temperatureStr, err := term.GetUserStringInputWithDefault("Temperature for "+role+":", fmt.Sprintf("%.1f", shared.DefaultConfigByRole[modelRole].Temperature))
215
+ // if err != nil {
216
+ // term.OutputErrorAndExit("Error reading temperature: %v", err)
217
+ // }
218
+ // temperature, err := strconv.ParseFloat(temperatureStr, 32)
219
+ // if err != nil {
220
+ // term.OutputErrorAndExit("Invalid number for temperature: %v", err)
221
+ // }
222
+
223
+ // topPStr, err := term.GetUserStringInputWithDefault("Top P for "+role+":", fmt.Sprintf("%.1f", shared.DefaultConfigByRole[modelRole].TopP))
224
+ // if err != nil {
225
+ // term.OutputErrorAndExit("Error reading top P: %v", err)
226
+ // }
227
+ // topP, err := strconv.ParseFloat(topPStr, 32)
228
+ // if err != nil {
229
+ // term.OutputErrorAndExit("Invalid number for top P: %v", err)
230
+ // }
231
+
232
+ // var reservedOutputTokens int
233
+ // if modelRole == shared.ModelRoleBuilder || modelRole == shared.ModelRolePlanner || modelRole == shared.ModelRoleWholeFileBuilder {
234
+ // reservedOutputTokensStr, err := term.GetUserStringInputWithDefault("Reserved output tokens for "+role+":", fmt.Sprintf("%d", model.ReservedOutputTokens))
235
+ // if err != nil {
236
+ // term.OutputErrorAndExit("Error reading reserved output tokens: %v", err)
237
+ // }
238
+ // reservedOutputTokens, err = strconv.Atoi(reservedOutputTokensStr)
239
+ // if err != nil {
240
+ // term.OutputErrorAndExit("Invalid number for reserved output tokens: %v", err)
241
+ // }
242
+ // }
243
+
244
+ // return model, shared.ModelRoleConfig{
245
+ // ModelId: model.ModelId,
246
+ // Role: modelRole,
247
+ // Temperature: float32(temperature),
248
+ // TopP: float32(topP),
249
+ // ReservedOutputTokens: reservedOutputTokens,
250
+ // }
251
+ // }
252
+
253
+ // func getPlannerRoleConfig(customModels []*shared.CustomModel) shared.PlannerRoleConfig {
254
+ // model, modelConfig := getModelWithRoleConfig(customModels, shared.ModelRolePlanner)
255
+
256
+ // return shared.PlannerRoleConfig{
257
+ // ModelRoleConfig: modelConfig,
258
+ // PlannerModelConfig: shared.PlannerModelConfig{
259
+ // MaxConvoTokens: model.DefaultMaxConvoTokens,
260
+ // },
261
+ // }
262
+ // }
263
+
264
+ // func getModelIdForRole(customModels []*shared.CustomModel, role shared.ModelRole) shared.ModelId {
265
+ // color.New(color.Bold).Printf("Select a model for the %s role 👇\n", role)
266
+ // return lib.SelectModelIdForRole(customModels, role)
267
+ // }
app/cli/cmd/model_providers.go ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "strconv"
7
+ "strings"
8
+
9
+ "plandex-cli/api"
10
+ "plandex-cli/auth"
11
+ "plandex-cli/term"
12
+
13
+ shared "plandex-shared"
14
+
15
+ "github.com/fatih/color"
16
+ "github.com/olekukonko/tablewriter"
17
+ "github.com/spf13/cobra"
18
+ )
19
+
20
+ var customProvidersOnly bool
21
+
22
+ var providersCmd = &cobra.Command{
23
+ Use: "providers",
24
+ Short: "List built-in and custom model providers",
25
+ Run: listProviders,
26
+ }
27
+
28
+ var addProviderCmd = &cobra.Command{
29
+ Use: "add",
30
+ Aliases: []string{"create"},
31
+ Short: "Add a custom model provider",
32
+ Run: customModelsNotImplemented,
33
+ }
34
+
35
+ var updateProviderCmd = &cobra.Command{
36
+ Use: "update",
37
+ Aliases: []string{"edit"},
38
+ Short: "Update a custom model provider",
39
+ Run: customModelsNotImplemented,
40
+ }
41
+
42
+ func init() {
43
+ RootCmd.AddCommand(providersCmd)
44
+ providersCmd.Flags().BoolVarP(&customProvidersOnly, "custom", "c", false, "List custom providers only")
45
+ providersCmd.AddCommand(addProviderCmd)
46
+ providersCmd.AddCommand(updateProviderCmd)
47
+ }
48
+
49
+ func listProviders(cmd *cobra.Command, args []string) {
50
+ auth.MustResolveAuthWithOrg()
51
+
52
+ var customProviders []*shared.CustomProvider
53
+ var apiErr *shared.ApiError
54
+
55
+ if customProvidersOnly && auth.Current.IsCloud {
56
+ term.OutputErrorAndExit("Custom providers are not supported on Plandex Cloud")
57
+ return
58
+ }
59
+
60
+ if !auth.Current.IsCloud {
61
+ term.StartSpinner("")
62
+ customProviders, apiErr = api.Client.ListCustomProviders()
63
+ term.StopSpinner()
64
+ if apiErr != nil {
65
+ term.OutputErrorAndExit("Error fetching providers: %v", apiErr.Msg)
66
+ return
67
+ }
68
+ }
69
+
70
+ if customProvidersOnly && len(customProviders) == 0 {
71
+ fmt.Println("🤷‍♂️ No custom providers")
72
+ fmt.Println()
73
+ term.PrintCmds("", "models custom")
74
+ return
75
+ }
76
+
77
+ color.New(color.Bold, term.ColorHiCyan).Println("🏠 Built-in Providers")
78
+ table := tablewriter.NewWriter(os.Stdout)
79
+ table.SetAutoWrapText(true)
80
+
81
+ var header []string
82
+ if auth.Current.IsCloud {
83
+ header = []string{"ID", "API Key", "Other Vars"}
84
+ } else {
85
+ header = []string{"ID", "Base URL", "API Key", "Other Vars"}
86
+ }
87
+ table.SetHeader(header)
88
+ for _, p := range shared.AllModelProviders {
89
+ if p == shared.ModelProviderCustom {
90
+ continue
91
+ }
92
+ config := shared.BuiltInModelProviderConfigs[p]
93
+ if config.LocalOnly && auth.Current.IsCloud {
94
+ continue
95
+ }
96
+ var apiKey string
97
+ if config.ApiKeyEnvVar != "" {
98
+ apiKey = config.ApiKeyEnvVar
99
+ } else if config.SkipAuth {
100
+ apiKey = "No Auth"
101
+ } else if config.HasClaudeMaxAuth {
102
+ apiKey = "Claude Max Oauth"
103
+ }
104
+
105
+ extraVars := []string{}
106
+ if config.Provider == shared.ModelProviderAmazonBedrock {
107
+ extraVars = append(extraVars, "PLANDEX_AWS_PROFILE")
108
+ }
109
+ for _, v := range config.ExtraAuthVars {
110
+ extraVars = append(extraVars, v.Var)
111
+ }
112
+
113
+ if auth.Current.IsCloud {
114
+ table.Append([]string{
115
+ string(p),
116
+ apiKey,
117
+ strings.Join(extraVars, "\n"),
118
+ })
119
+ } else {
120
+ table.Append([]string{
121
+ string(p),
122
+ config.BaseUrl,
123
+ apiKey,
124
+ strings.Join(extraVars, "\n"),
125
+ })
126
+ }
127
+ }
128
+ table.Render()
129
+ fmt.Println()
130
+
131
+ if len(customProviders) > 0 {
132
+ color.New(color.Bold, term.ColorHiCyan).Println("🛠️ Custom Providers")
133
+ table := tablewriter.NewWriter(os.Stdout)
134
+ table.SetAutoWrapText(false)
135
+ table.SetHeader([]string{"#", "Name", "Base URL", "API Key", "Other Vars"})
136
+ for i, p := range customProviders {
137
+ extraVars := []string{}
138
+ for _, v := range p.ExtraAuthVars {
139
+ extraVars = append(extraVars, v.Var)
140
+ }
141
+ apiKey := p.ApiKeyEnvVar
142
+ if apiKey == "" && p.SkipAuth {
143
+ apiKey = "No Auth"
144
+ }
145
+
146
+ table.Append([]string{
147
+ strconv.Itoa(i + 1),
148
+ p.Name,
149
+ p.BaseUrl,
150
+ apiKey,
151
+ strings.Join(extraVars, "\n"),
152
+ })
153
+ }
154
+ table.Render()
155
+ fmt.Println()
156
+ }
157
+
158
+ fmt.Println(color.New(color.Bold, term.ColorHiCyan).Sprint("\n📖 Per-provider instructions"))
159
+ fmt.Println("Go to → " + color.New(color.Bold).Sprint("https://docs.plandex.ai/models/model-providers"))
160
+ fmt.Println()
161
+
162
+ term.PrintCmds("", "models custom")
163
+ }
app/cli/cmd/models.go ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+
9
+ "plandex-cli/api"
10
+ "plandex-cli/auth"
11
+ "plandex-cli/fs"
12
+ "plandex-cli/lib"
13
+ "plandex-cli/term"
14
+ "strconv"
15
+
16
+ shared "plandex-shared"
17
+
18
+ "github.com/fatih/color"
19
+ "github.com/olekukonko/tablewriter"
20
+ "github.com/spf13/cobra"
21
+ )
22
+
23
+ var customModelsOnly bool
24
+ var allProperties bool
25
+ var saveCustomModels bool
26
+ var customModelsPath string
27
+
28
+ func init() {
29
+ RootCmd.AddCommand(modelsCmd)
30
+
31
+ modelsCmd.Flags().BoolVarP(&allProperties, "all", "a", false, "Show all properties")
32
+
33
+ modelsCmd.AddCommand(listAvailableModelsCmd)
34
+ modelsCmd.AddCommand(addCustomModelCmd)
35
+ modelsCmd.AddCommand(updateCustomModelCmd)
36
+ modelsCmd.AddCommand(manageCustomModelsCmd)
37
+ modelsCmd.AddCommand(deleteCustomModelCmd)
38
+ modelsCmd.AddCommand(defaultModelsCmd)
39
+
40
+ manageCustomModelsCmd.Flags().BoolVar(&saveCustomModels, "save", false, "Save custom models")
41
+ manageCustomModelsCmd.Flags().StringVarP(&customModelsPath, "file", "f", "", "Path to custom models file")
42
+
43
+ listAvailableModelsCmd.Flags().BoolVarP(&customModelsOnly, "custom", "c", false, "List custom models only")
44
+ }
45
+
46
+ var modelsCmd = &cobra.Command{
47
+ Use: "models",
48
+ Short: "Show plan model settings",
49
+ Run: models,
50
+ }
51
+
52
+ var defaultModelsCmd = &cobra.Command{
53
+ Use: "default",
54
+ Short: "Show default model settings for new plans",
55
+ Run: defaultModels,
56
+ }
57
+
58
+ var listAvailableModelsCmd = &cobra.Command{
59
+ Use: "available",
60
+ Aliases: []string{"avail"},
61
+ Short: "List all available models",
62
+ Run: listAvailableModels,
63
+ }
64
+
65
+ var manageCustomModelsCmd = &cobra.Command{
66
+ Use: "custom",
67
+ Short: "Manage custom models, providers, and model packs",
68
+ Run: manageCustomModels,
69
+ }
70
+
71
+ var addCustomModelCmd = &cobra.Command{
72
+ Use: "add",
73
+ Aliases: []string{"create"},
74
+ Short: "Add a custom model",
75
+ Run: customModelsNotImplemented,
76
+ }
77
+
78
+ var updateCustomModelCmd = &cobra.Command{
79
+ Use: "update",
80
+ Aliases: []string{"edit"},
81
+ Short: "Update a custom model",
82
+ Run: customModelsNotImplemented,
83
+ }
84
+
85
+ var deleteCustomModelCmd = &cobra.Command{
86
+ Use: "rm",
87
+ Aliases: []string{"remove", "delete"},
88
+ Short: "Remove a custom model",
89
+ Args: cobra.MaximumNArgs(1),
90
+ Run: customModelsNotImplemented,
91
+ }
92
+
93
+ func manageCustomModels(cmd *cobra.Command, args []string) {
94
+ auth.MustResolveAuthWithOrg()
95
+
96
+ term.StartSpinner("")
97
+
98
+ var serverModelsInput *shared.ModelsInput
99
+ var defaultConfig *shared.PlanConfig
100
+
101
+ errCh := make(chan error, 2)
102
+
103
+ go func() {
104
+ var err error
105
+ serverModelsInput, err = lib.GetServerModelsInput()
106
+ if err != nil {
107
+ errCh <- fmt.Errorf("error getting server models input: %v", err)
108
+ return
109
+ }
110
+ errCh <- nil
111
+ }()
112
+
113
+ go func() {
114
+ var apiErr *shared.ApiError
115
+ defaultConfig, apiErr = api.Client.GetDefaultPlanConfig()
116
+ if apiErr != nil {
117
+ errCh <- fmt.Errorf("error getting default config: %v", apiErr.Msg)
118
+ return
119
+ }
120
+ errCh <- nil
121
+ }()
122
+
123
+ for i := 0; i < 2; i++ {
124
+ err := <-errCh
125
+ if err != nil {
126
+ term.OutputErrorAndExit(err.Error())
127
+ return
128
+ }
129
+ }
130
+
131
+ usingDefaultPath := false
132
+ if customModelsPath == "" {
133
+ usingDefaultPath = true
134
+ customModelsPath = lib.GetCustomModelsPath(auth.Current.UserId)
135
+ }
136
+
137
+ exists, err := fs.FileExists(customModelsPath)
138
+ if err != nil {
139
+ term.OutputErrorAndExit("Error checking custom models file: %v", err)
140
+ return
141
+ }
142
+
143
+ if saveCustomModels {
144
+ if !exists {
145
+ term.OutputErrorAndExit("File not found: %s", customModelsPath)
146
+ return
147
+ }
148
+ } else {
149
+ if serverModelsInput.IsEmpty() {
150
+ jsonData, err := json.MarshalIndent(getExampleTemplate(auth.Current.IsCloud, auth.Current.IntegratedModelsMode), "", " ")
151
+ if err != nil {
152
+ term.OutputErrorAndExit("Error marshalling template: %v", err)
153
+ return
154
+ }
155
+
156
+ err = os.MkdirAll(filepath.Dir(customModelsPath), 0755)
157
+ if err != nil {
158
+ term.OutputErrorAndExit("Error creating directory: %v", err)
159
+ return
160
+ }
161
+
162
+ err = os.WriteFile(customModelsPath, jsonData, 0644)
163
+ if err != nil {
164
+ term.OutputErrorAndExit("Error writing template file: %v", err)
165
+ return
166
+ }
167
+
168
+ term.StopSpinner()
169
+
170
+ fmt.Printf("🧠 Example models file → %s\n", customModelsPath)
171
+ fmt.Println("👨‍💻 Edit it, then come back here to save")
172
+ fmt.Println()
173
+ } else {
174
+ serverClientModelsInput := serverModelsInput.ToClientModelsInput()
175
+ serverClientModelsInput.PrepareUpdate()
176
+
177
+ var localModelsInput shared.ModelsInput
178
+
179
+ if exists {
180
+ // we only do a conflict check on the default path in the home dir
181
+ // if user specifies the path and the file exists, just open the file without checking for conflicts
182
+ if usingDefaultPath {
183
+ res, err := lib.CustomModelsCheckLocalChanges(customModelsPath)
184
+ if err != nil {
185
+ term.OutputErrorAndExit("Error checking local changes: %v", err)
186
+ return
187
+ }
188
+
189
+ localModelsInput = res.LocalModelsInput
190
+ if res.HasLocalChanges {
191
+ term.StopSpinner()
192
+
193
+ res, err := warnModelsFileLocalChanges(customModelsPath, "models custom")
194
+ if err != nil {
195
+ term.OutputErrorAndExit("Error confirming: %v", err)
196
+ return
197
+ }
198
+ if !res {
199
+ return
200
+ }
201
+ fmt.Println()
202
+ term.StartSpinner("")
203
+ }
204
+ }
205
+ }
206
+
207
+ if !serverModelsInput.Equals(localModelsInput) {
208
+ err := lib.WriteCustomModelsFile(customModelsPath, serverModelsInput)
209
+ if err != nil {
210
+ term.OutputErrorAndExit("Error saving custom models file: %v", err)
211
+ return
212
+ }
213
+ }
214
+
215
+ term.StopSpinner()
216
+
217
+ fmt.Printf("🧠 %s → %s\n", color.New(color.Bold, term.ColorHiCyan).Sprint("Models file"), customModelsPath)
218
+ fmt.Println("👨‍💻 Edit it, then come back here to save")
219
+ fmt.Println()
220
+ }
221
+
222
+ pathArg := ""
223
+ if !usingDefaultPath {
224
+ pathArg = fmt.Sprintf(" --file %s", customModelsPath)
225
+ }
226
+
227
+ res := maybePromptAndOpenModelsFile(customModelsPath, pathArg, "models custom", defaultConfig, nil)
228
+ if res.shouldReturn {
229
+ return
230
+ }
231
+ }
232
+
233
+ didUpdate := lib.MustSyncCustomModels(customModelsPath, serverModelsInput)
234
+
235
+ if !didUpdate {
236
+ fmt.Println("🤷‍♂️ No changes to custom models/providers/model packs")
237
+ }
238
+ }
239
+
240
+ func models(cmd *cobra.Command, args []string) {
241
+ auth.MustResolveAuthWithOrg()
242
+ lib.MustResolveProject()
243
+
244
+ if lib.CurrentPlanId == "" {
245
+ term.OutputNoCurrentPlanErrorAndExit()
246
+ }
247
+
248
+ term.StartSpinner("")
249
+
250
+ plan, err := api.Client.GetPlan(lib.CurrentPlanId)
251
+
252
+ if err != nil {
253
+ term.StopSpinner()
254
+ term.OutputErrorAndExit("Error getting plan: %v", err)
255
+ return
256
+ }
257
+
258
+ settings, err := api.Client.GetSettings(lib.CurrentPlanId, lib.CurrentBranch)
259
+ term.StopSpinner()
260
+
261
+ if err != nil {
262
+ term.OutputErrorAndExit("Error getting settings: %v", err)
263
+ return
264
+ }
265
+
266
+ title := fmt.Sprintf("%s Model Settings", color.New(color.Bold, term.ColorHiGreen).Sprint(plan.Name))
267
+
268
+ table := tablewriter.NewWriter(os.Stdout)
269
+ table.SetAutoWrapText(false)
270
+ table.Append([]string{title})
271
+ table.Render()
272
+ fmt.Println()
273
+
274
+ renderSettings(settings, allProperties)
275
+
276
+ term.PrintCmds("", "set-model", "models available", "models default", "models custom")
277
+ }
278
+
279
+ func defaultModels(cmd *cobra.Command, args []string) {
280
+ auth.MustResolveAuthWithOrg()
281
+
282
+ term.StartSpinner("")
283
+ settings, err := api.Client.GetOrgDefaultSettings()
284
+
285
+ if err != nil {
286
+ term.OutputErrorAndExit("Error getting default model settings: %v", err)
287
+ return
288
+ }
289
+
290
+ term.StopSpinner()
291
+
292
+ title := fmt.Sprintf("%s Model Settings", color.New(color.Bold, term.ColorHiGreen).Sprint("Org-Wide Default"))
293
+ table := tablewriter.NewWriter(os.Stdout)
294
+ table.SetAutoWrapText(false)
295
+ table.Append([]string{title})
296
+ table.Render()
297
+ fmt.Println()
298
+
299
+ renderSettings(settings, allProperties)
300
+
301
+ term.PrintCmds("", "set-model default", "models available", "models")
302
+ }
303
+
304
+ func listAvailableModels(cmd *cobra.Command, args []string) {
305
+ auth.MustResolveAuthWithOrg()
306
+
307
+ if customModelsOnly && auth.Current.IntegratedModelsMode {
308
+ term.OutputErrorAndExit("Custom models are not supported in Integrated Models mode on Plandex Cloud")
309
+ return
310
+ }
311
+
312
+ term.StartSpinner("")
313
+
314
+ customModels, err := api.Client.ListCustomModels()
315
+
316
+ term.StopSpinner()
317
+
318
+ if err != nil {
319
+ term.OutputErrorAndExit("Error fetching custom models: %v", err)
320
+ return
321
+ }
322
+
323
+ if !customModelsOnly {
324
+ color.New(color.Bold, term.ColorHiCyan).Println("🏠 Built-in Models")
325
+ table := tablewriter.NewWriter(os.Stdout)
326
+ table.SetAutoWrapText(false)
327
+ table.SetHeader([]string{"Model", "Input", "Output", "Reserved"})
328
+ for _, model := range shared.BuiltInBaseModels {
329
+ if auth.Current.IsCloud && model.IsLocalOnly() {
330
+ continue
331
+ }
332
+
333
+ table.Append([]string{string(model.ModelId), fmt.Sprintf("%d 🪙", model.MaxTokens), fmt.Sprintf("%d 🪙", model.MaxOutputTokens), fmt.Sprintf("%d 🪙", model.ReservedOutputTokens)})
334
+ }
335
+ table.Render()
336
+ fmt.Println()
337
+ }
338
+
339
+ if len(customModels) > 0 {
340
+ color.New(color.Bold, term.ColorHiCyan).Println("🛠️ Custom Models")
341
+ table := tablewriter.NewWriter(os.Stdout)
342
+ table.SetAutoWrapText(false)
343
+ table.SetHeader([]string{"#", "ID", "🪙"})
344
+ for i, model := range customModels {
345
+ table.Append([]string{fmt.Sprintf("%d", i+1), string(model.ModelId), strconv.Itoa(model.MaxTokens)})
346
+ }
347
+ table.Render()
348
+ } else if customModelsOnly {
349
+ fmt.Println("🤷‍♂️ No custom models")
350
+ }
351
+ fmt.Println()
352
+
353
+ if customModelsOnly {
354
+ if len(customModels) > 0 {
355
+ term.PrintCmds("", "models", "set-model", "models custom")
356
+ } else {
357
+ term.PrintCmds("", "models custom")
358
+ }
359
+ } else {
360
+ term.PrintCmds("", "models available --custom", "models", "set-model", "models custom")
361
+ }
362
+ }
363
+
364
+ func renderSettings(settings *shared.PlanSettings, allProperties bool) {
365
+ modelPack := settings.GetModelPack()
366
+
367
+ color.New(color.Bold, term.ColorHiCyan).Println("🎛️ Current Model Pack")
368
+ renderModelPack(modelPack, settings.CustomModelsById, allProperties)
369
+
370
+ if allProperties {
371
+ color.New(color.Bold, term.ColorHiCyan).Println("🧠 Planner Defaults")
372
+ table := tablewriter.NewWriter(os.Stdout)
373
+ table.SetAutoWrapText(false)
374
+ table.SetHeader([]string{"Max Tokens", "Max Convo Tokens"})
375
+ table.Append([]string{
376
+ fmt.Sprintf("%d", modelPack.Planner.GetFinalLargeContextFallback().GetSharedBaseConfig(settings).MaxTokens),
377
+ fmt.Sprintf("%d", modelPack.Planner.GetMaxConvoTokens(settings)),
378
+ })
379
+ table.Render()
380
+ fmt.Println()
381
+ }
382
+ }
383
+
384
+ func renderModelPack(modelPack *shared.ModelPack, customModelsById map[shared.ModelId]*shared.CustomModel, allProperties bool) {
385
+ table := tablewriter.NewWriter(os.Stdout)
386
+ table.SetAutoFormatHeaders(false)
387
+ table.SetAutoWrapText(true)
388
+ table.SetColWidth(64)
389
+ table.SetHeader([]string{modelPack.Name})
390
+ table.Append([]string{modelPack.Description})
391
+ table.Render()
392
+ fmt.Println()
393
+
394
+ color.New(color.Bold, term.ColorHiCyan).Println("🤖 Models")
395
+ table = tablewriter.NewWriter(os.Stdout)
396
+ table.SetAutoWrapText(false)
397
+ cols := []string{
398
+ "Role",
399
+ "Model",
400
+ }
401
+ align := []int{
402
+ tablewriter.ALIGN_LEFT, // Role
403
+ tablewriter.ALIGN_LEFT, // Model
404
+ }
405
+ if allProperties {
406
+ cols = append(cols, []string{
407
+ "Temperature",
408
+ "Top P",
409
+ "Max Input",
410
+ }...)
411
+ align = append(align, []int{
412
+ tablewriter.ALIGN_RIGHT, // Temperature
413
+ tablewriter.ALIGN_RIGHT, // Top P
414
+ tablewriter.ALIGN_RIGHT, // Max Input
415
+ }...)
416
+ }
417
+ table.SetHeader(cols)
418
+ table.SetColumnAlignment(align)
419
+
420
+ anyRoleParamsDisabled := false
421
+
422
+ var addModelRow func(role string, config shared.ModelRoleConfig, indent int)
423
+ addModelRow = func(role string, config shared.ModelRoleConfig, indent int) {
424
+ if indent > 0 {
425
+ role = "└─ " + role
426
+ for i := 0; i < indent-1; i++ {
427
+ role = " " + role
428
+ }
429
+ }
430
+
431
+ var temp float32
432
+ var topP float32
433
+ var disabled bool
434
+
435
+ sharedBaseConfig := config.GetSharedBaseConfigWithCustomModels(customModelsById)
436
+
437
+ if sharedBaseConfig.RoleParamsDisabled {
438
+ temp = 1
439
+ topP = 1
440
+ disabled = true
441
+ anyRoleParamsDisabled = true
442
+ } else {
443
+ temp = config.Temperature
444
+ topP = config.TopP
445
+ }
446
+
447
+ tempStr := fmt.Sprintf("%.1f", temp)
448
+ if disabled {
449
+ tempStr = "*" + tempStr
450
+ }
451
+
452
+ topPStr := fmt.Sprintf("%.1f", topP)
453
+ if disabled {
454
+ topPStr = "*" + topPStr
455
+ }
456
+
457
+ row := []string{
458
+ role,
459
+ string(config.GetModelId()),
460
+ }
461
+
462
+ if allProperties {
463
+ row = append(row, []string{
464
+ tempStr,
465
+ topPStr,
466
+ fmt.Sprintf("%d 🪙", sharedBaseConfig.MaxTokens-config.GetReservedOutputTokens(customModelsById)),
467
+ }...)
468
+ }
469
+ table.Append(row)
470
+
471
+ // Add large context and large output fallback(s) if present
472
+ if config.LargeContextFallback != nil {
473
+ addModelRow("large-context", *config.LargeContextFallback, indent+1)
474
+ }
475
+
476
+ if config.LargeOutputFallback != nil {
477
+ addModelRow("large-output", *config.LargeOutputFallback, indent+1)
478
+ }
479
+
480
+ if config.StrongModel != nil {
481
+ addModelRow("strong", *config.StrongModel, indent+1)
482
+ }
483
+
484
+ if config.ErrorFallback != nil {
485
+ addModelRow("error", *config.ErrorFallback, indent+1)
486
+ }
487
+ }
488
+
489
+ addModelRow(string(shared.ModelRolePlanner), modelPack.Planner.ModelRoleConfig, 0)
490
+
491
+ addModelRow(string(shared.ModelRoleArchitect), modelPack.GetArchitect(), 0)
492
+ addModelRow(string(shared.ModelRoleCoder), modelPack.GetCoder(), 0)
493
+ addModelRow(string(shared.ModelRolePlanSummary), modelPack.PlanSummary, 0)
494
+ addModelRow(string(shared.ModelRoleBuilder), modelPack.Builder, 0)
495
+ addModelRow(string(shared.ModelRoleWholeFileBuilder), modelPack.GetWholeFileBuilder(), 0)
496
+ addModelRow(string(shared.ModelRoleName), modelPack.Namer, 0)
497
+ addModelRow(string(shared.ModelRoleCommitMsg), modelPack.CommitMsg, 0)
498
+ addModelRow(string(shared.ModelRoleExecStatus), modelPack.ExecStatus, 0)
499
+ table.Render()
500
+
501
+ if anyRoleParamsDisabled && allProperties {
502
+ fmt.Println("* these models do not support changing temperature or top p")
503
+ }
504
+
505
+ fmt.Println()
506
+
507
+ }
508
+
509
+ func customModelsNotImplemented(cmd *cobra.Command, args []string) {
510
+ color.New(color.Bold, color.FgHiRed).Println("⛔️ Not implemented")
511
+ fmt.Println()
512
+ fmt.Println("Use " + color.New(color.BgCyan, color.FgHiWhite).Sprint(" plandex models custom ") + " to manage custom models, providers, and model packs")
513
+ os.Exit(1)
514
+ }
515
+
516
+ func getExampleTemplate(isCloud, isCloudIntegratedModels bool) shared.ClientModelsInput {
517
+ exampleProviderName := "togetherai"
518
+
519
+ var customProviders []*shared.CustomProvider
520
+ usesProviders := []shared.BaseModelUsesProvider{}
521
+ if !isCloud {
522
+ customProviders = append(customProviders, &shared.CustomProvider{
523
+ Name: exampleProviderName,
524
+ BaseUrl: "https://api.together.xyz/v1",
525
+ ApiKeyEnvVar: "TOGETHER_API_KEY",
526
+ })
527
+
528
+ usesProviders = append(usesProviders, shared.BaseModelUsesProvider{
529
+ Provider: shared.ModelProviderCustom,
530
+ CustomProvider: &exampleProviderName,
531
+ ModelName: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
532
+ })
533
+ }
534
+ usesProviders = append(usesProviders, shared.BaseModelUsesProvider{
535
+ Provider: shared.ModelProviderOpenRouter,
536
+ ModelName: "meta-llama/llama-4-maverick",
537
+ })
538
+
539
+ var customModels []*shared.CustomModel
540
+ if !isCloudIntegratedModels {
541
+ customModels = []*shared.CustomModel{
542
+ {
543
+ ModelId: shared.ModelId("meta-llama/llama-4-maverick"),
544
+ Publisher: shared.ModelPublisher("meta-llama"),
545
+ Description: "Meta Llama 4 Maverick",
546
+
547
+ BaseModelShared: shared.BaseModelShared{
548
+ DefaultMaxConvoTokens: 75000,
549
+ MaxTokens: 1048576,
550
+ MaxOutputTokens: 16000,
551
+ ReservedOutputTokens: 16000,
552
+ ModelCompatibility: shared.FullCompatibility,
553
+ PreferredOutputFormat: shared.ModelOutputFormatXml,
554
+ },
555
+
556
+ Providers: usesProviders,
557
+ },
558
+ }
559
+ }
560
+
561
+ lightModelId := "meta-llama/llama-4-maverick"
562
+ if len(customModels) == 0 {
563
+ lightModelId = "mistral/devstral-small"
564
+ }
565
+
566
+ return shared.ClientModelsInput{
567
+ SchemaUrl: shared.SchemaUrlInputConfig,
568
+ CustomProviders: customProviders,
569
+ CustomModels: customModels,
570
+ CustomModelPacks: []*shared.ClientModelPackSchema{
571
+ {
572
+ Name: "example-model-pack",
573
+ Description: "Example model pack",
574
+ ClientModelPackSchemaRoles: shared.ClientModelPackSchemaRoles{
575
+ Planner: "deepseek/r1",
576
+ Architect: "deepseek/r1",
577
+ Coder: &shared.ModelRoleConfigSchema{
578
+ ModelId: "deepseek/v3",
579
+ LargeContextFallback: &shared.ModelRoleConfigSchema{
580
+ ModelId: "google/gemini-2.5-pro",
581
+ },
582
+ ErrorFallback: &shared.ModelRoleConfigSchema{
583
+ ModelId: "deepseek/r1-hidden",
584
+ },
585
+ },
586
+ PlanSummary: lightModelId,
587
+ Builder: shared.ModelRoleConfigSchema{
588
+ ModelId: "deepseek/r1-hidden",
589
+ StrongModel: &shared.ModelRoleConfigSchema{
590
+ ModelId: "openai/o3-medium",
591
+ },
592
+ },
593
+ WholeFileBuilder: shared.ModelRoleConfigSchema{
594
+ ModelId: "deepseek/r1-hidden",
595
+ LargeContextFallback: &shared.ModelRoleConfigSchema{
596
+ ModelId: "google/gemini-2.5-pro",
597
+ LargeOutputFallback: &shared.ModelRoleConfigSchema{
598
+ ModelId: "openai/o3-low",
599
+ },
600
+ },
601
+ LargeOutputFallback: &shared.ModelRoleConfigSchema{
602
+ ModelId: "openai/o3-low",
603
+ },
604
+ },
605
+ ExecStatus: "deepseek/r1-hidden",
606
+ Namer: lightModelId,
607
+ CommitMsg: lightModelId,
608
+ },
609
+ },
610
+ },
611
+ }
612
+ }
app/cli/cmd/new.go ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+
6
+ "plandex-cli/api"
7
+ "plandex-cli/auth"
8
+ "plandex-cli/lib"
9
+ "plandex-cli/term"
10
+ "plandex-cli/types"
11
+
12
+ shared "plandex-shared"
13
+
14
+ "github.com/fatih/color"
15
+ "github.com/spf13/cobra"
16
+ )
17
+
18
+ var name string
19
+ var contextBaseDir string
20
+
21
+ // newCmd represents the new command
22
+ var newCmd = &cobra.Command{
23
+ Use: "new",
24
+ Aliases: []string{"n"},
25
+ Short: "Start a new plan",
26
+ // Long: ``,
27
+ Args: cobra.ExactArgs(0),
28
+ Run: new,
29
+ }
30
+
31
+ func init() {
32
+ RootCmd.AddCommand(newCmd)
33
+ newCmd.Flags().StringVarP(&name, "name", "n", "", "Name of the new plan")
34
+ newCmd.Flags().StringVar(&contextBaseDir, "context-dir", ".", "Base directory to auto-load context from")
35
+
36
+ AddNewPlanFlags(newCmd)
37
+ }
38
+
39
+ func new(cmd *cobra.Command, args []string) {
40
+ auth.MustResolveAuthWithOrg()
41
+ lib.MustResolveOrCreateProject()
42
+
43
+ term.StartSpinner("")
44
+
45
+ errCh := make(chan error, 2)
46
+
47
+ var planId string
48
+ var config *shared.PlanConfig
49
+
50
+ go func() {
51
+ res, apiErr := api.Client.CreatePlan(lib.CurrentProjectId, shared.CreatePlanRequest{Name: name})
52
+ if apiErr != nil {
53
+ errCh <- fmt.Errorf("error creating plan: %v", apiErr.Msg)
54
+ return
55
+ }
56
+ planId = res.Id
57
+ errCh <- nil
58
+ }()
59
+
60
+ go func() {
61
+ var apiErr *shared.ApiError
62
+ config, apiErr = api.Client.GetDefaultPlanConfig()
63
+ if apiErr != nil {
64
+ errCh <- fmt.Errorf("error getting plan config: %v", apiErr.Msg)
65
+ return
66
+ }
67
+ errCh <- nil
68
+ }()
69
+
70
+ for i := 0; i < 2; i++ {
71
+ err := <-errCh
72
+ if err != nil {
73
+ term.OutputErrorAndExit("Error: %v", err)
74
+ }
75
+ }
76
+
77
+ err := lib.WriteCurrentPlan(planId)
78
+
79
+ if err != nil {
80
+ term.OutputErrorAndExit("Error setting current plan: %v", err)
81
+ }
82
+
83
+ err = lib.WriteCurrentBranch("main")
84
+ if err != nil {
85
+ term.OutputErrorAndExit("Error setting current branch: %v", err)
86
+ }
87
+
88
+ if name == "" {
89
+ name = "draft"
90
+ }
91
+
92
+ term.StopSpinner()
93
+
94
+ fmt.Printf("✅ Started new plan %s and set it to current plan\n", color.New(color.Bold, term.ColorHiGreen).Sprint(name))
95
+ fmt.Printf("⚙️ Using default config\n")
96
+
97
+ resolveAutoMode(config)
98
+
99
+ resolveModelPack()
100
+
101
+ // autoModeLabel := shared.ConfigSettingsByKey["automode"].KeyToLabel(string(config.AutoMode))
102
+ // fmt.Println("⚡️ Auto-mode:", autoModeLabel)
103
+
104
+ if config.AutoLoadContext {
105
+ fmt.Println("📥 Automatic context loading is enabled")
106
+
107
+ baseDir := contextBaseDir
108
+ if baseDir == "" {
109
+ baseDir = "."
110
+ }
111
+
112
+ lib.MustLoadContext([]string{baseDir}, &types.LoadContextParams{
113
+ DefsOnly: true,
114
+ SkipIgnoreWarning: true,
115
+ AutoLoaded: true,
116
+ })
117
+ } else {
118
+ fmt.Println()
119
+ }
120
+
121
+ var cmds []string
122
+ if term.IsRepl {
123
+ cmds = []string{"config", "plans", "cd", "models"}
124
+ } else {
125
+ cmds = []string{"tell", "chat", "config"}
126
+ }
127
+
128
+ if !config.AutoLoadContext {
129
+ cmds = append([]string{"load"}, cmds...)
130
+ }
131
+
132
+ fmt.Println()
133
+ term.PrintCmds("", cmds...)
134
+ }
app/cli/cmd/plan_exec_helpers.go ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "plandex-cli/lib"
7
+ "plandex-cli/term"
8
+ "strconv"
9
+
10
+ shared "plandex-shared"
11
+
12
+ "github.com/spf13/cobra"
13
+ )
14
+
15
+ const (
16
+ EditorTypeVim string = "vim"
17
+ EditorTypeNano string = "nano"
18
+ )
19
+
20
+ var defaultEditor = EditorTypeVim
21
+
22
+ const defaultAutoDebugTries = 5
23
+
24
+ var autoConfirm bool
25
+
26
+ var tellPromptFile string
27
+ var tellBg bool
28
+ var tellStop bool
29
+ var tellNoBuild bool
30
+ var tellAutoApply bool
31
+ var tellAutoContext bool
32
+ var tellSmartContext bool
33
+ var tellSkipMenu bool
34
+ var noExec bool
35
+ var autoDebug int
36
+
37
+ var editor = EditorTypeVim // default to vim
38
+ var editorSetByFlag bool
39
+
40
+ func init() {
41
+ envEditor := os.Getenv("EDITOR")
42
+ if envEditor == "" {
43
+ envEditor = os.Getenv("VISUAL")
44
+ }
45
+
46
+ if envEditor != "" {
47
+ defaultEditor = envEditor
48
+ }
49
+ }
50
+
51
+ type initExecFlagsParams struct {
52
+ omitFile bool
53
+ omitNoBuild bool
54
+ omitEditor bool
55
+ omitStop bool
56
+ omitBg bool
57
+ omitApply bool
58
+ omitExec bool
59
+ omitAutoContext bool
60
+ omitSmartContext bool
61
+ omitSkipMenu bool
62
+ }
63
+
64
+ func initExecFlags(cmd *cobra.Command, params initExecFlagsParams) {
65
+ if !params.omitFile {
66
+ cmd.Flags().StringVarP(&tellPromptFile, "file", "f", "", "File containing prompt")
67
+ }
68
+
69
+ if !params.omitBg {
70
+ cmd.Flags().BoolVar(&tellBg, "bg", false, "Execute autonomously in the background")
71
+ }
72
+
73
+ if !params.omitStop {
74
+ cmd.Flags().BoolVarP(&tellStop, "stop", "s", false, "Stop after a single reply")
75
+ }
76
+
77
+ if !params.omitNoBuild {
78
+ cmd.Flags().BoolVarP(&tellNoBuild, "no-build", "n", false, "Don't build files")
79
+ }
80
+
81
+ cmd.Flags().BoolVar(&autoConfirm, "auto-update-context", false, shared.ConfigSettingsByKey["auto-update-context"].Desc)
82
+
83
+ if !params.omitAutoContext {
84
+ cmd.Flags().BoolVar(&tellAutoContext, "auto-load-context", false, shared.ConfigSettingsByKey["auto-load-context"].Desc)
85
+ }
86
+
87
+ if !params.omitSmartContext {
88
+ cmd.Flags().BoolVar(&tellSmartContext, "smart-context", false, shared.ConfigSettingsByKey["smart-context"].Desc)
89
+ }
90
+
91
+ if !params.omitApply {
92
+ cmd.Flags().BoolVar(&tellAutoApply, "apply", false, "Automatically apply changes")
93
+ initApplyFlags(cmd, true)
94
+ }
95
+
96
+ if !params.omitExec {
97
+ initExecScriptFlags(cmd)
98
+ }
99
+
100
+ if !params.omitEditor {
101
+ cmd.Flags().Var(newEditorValue(&editor), "editor", "Write prompt in system editor")
102
+ cmd.Flag("editor").NoOptDefVal = defaultEditor
103
+ }
104
+
105
+ if !params.omitSkipMenu {
106
+ cmd.Flags().BoolVar(&tellSkipMenu, "skip-menu", false, shared.ConfigSettingsByKey["skip-changes-menu"].Desc)
107
+ }
108
+ }
109
+
110
+ func initApplyFlags(cmd *cobra.Command, applyFlag bool) {
111
+ commitDesc := "Commit changes to git"
112
+ if applyFlag {
113
+ commitDesc += " when --apply is passed"
114
+ }
115
+
116
+ skipCommitDesc := "Skip committing changes to git"
117
+ if applyFlag {
118
+ skipCommitDesc += " when --apply is passed"
119
+ }
120
+ cmd.Flags().BoolVarP(&autoCommit, "commit", "c", false, commitDesc)
121
+ cmd.Flags().BoolVar(&skipCommit, "skip-commit", false, skipCommitDesc)
122
+ }
123
+
124
+ func initExecScriptFlags(cmd *cobra.Command) {
125
+ cmd.Flags().BoolVar(&noExec, "no-exec", false, "Disable command execution")
126
+ cmd.Flags().BoolVar(&autoExec, "auto-exec", false, "Automatically execute commands without confirmation")
127
+ cmd.Flags().Var(newAutoDebugValue(&autoDebug), "debug", "Automatically execute and debug failing commands (optionally specify number of tries—default is 5)")
128
+ cmd.Flag("debug").NoOptDefVal = strconv.Itoa(defaultAutoDebugTries)
129
+ }
130
+
131
+ func validatePlanExecFlags(isApply bool) {
132
+ if autoDebug > 0 && noExec {
133
+ term.OutputErrorAndExit("--debug can't be used with --no-exec")
134
+ }
135
+
136
+ if tellAutoContext && tellBg {
137
+ term.OutputErrorAndExit("--auto-context/-c can't be used with --bg")
138
+ }
139
+
140
+ if !isApply {
141
+ if autoDebug > 0 && !tellAutoApply {
142
+ term.OutputErrorAndExit("--debug can only be used with --apply")
143
+ }
144
+
145
+ if autoExec && !tellAutoApply {
146
+ term.OutputErrorAndExit("--auto-exec can only be used with --apply")
147
+ }
148
+
149
+ if tellAutoApply && tellNoBuild {
150
+ term.OutputErrorAndExit("--apply can't be used with --no-build/-n")
151
+ }
152
+ if tellAutoApply && tellBg {
153
+ term.OutputErrorAndExit("--apply can't be used with --bg")
154
+ }
155
+ }
156
+ }
157
+
158
+ func mustSetPlanExecFlags(cmd *cobra.Command, isApply bool) {
159
+ if lib.CurrentPlanId == "" {
160
+ term.OutputNoCurrentPlanErrorAndExit()
161
+ }
162
+
163
+ config := lib.MustGetCurrentPlanConfig()
164
+
165
+ // Set flag vars from config when flags aren't explicitly set
166
+ if !cmd.Flags().Changed("stop") {
167
+ tellStop = !config.AutoContinue
168
+ }
169
+
170
+ if !cmd.Flags().Changed("no-build") {
171
+ tellNoBuild = !config.AutoBuild
172
+ }
173
+
174
+ if !cmd.Flags().Changed("auto-update-context") {
175
+ autoConfirm = config.AutoUpdateContext
176
+ }
177
+ if !cmd.Flags().Changed("apply") {
178
+ tellAutoApply = config.AutoApply
179
+ }
180
+ if !cmd.Flags().Changed("skip-commit") {
181
+ skipCommit = config.SkipCommit
182
+ }
183
+ if !cmd.Flags().Changed("commit") {
184
+ autoCommit = config.AutoCommit
185
+ }
186
+ if !cmd.Flags().Changed("auto-load-context") {
187
+ tellAutoContext = config.AutoLoadContext
188
+ }
189
+ if !cmd.Flags().Changed("smart-context") {
190
+ tellSmartContext = config.SmartContext
191
+ }
192
+ if !cmd.Flags().Changed("no-exec") {
193
+ noExec = !config.CanExec
194
+ }
195
+ if !cmd.Flags().Changed("auto-exec") {
196
+ autoExec = config.AutoExec
197
+ }
198
+ if !cmd.Flags().Changed("debug") {
199
+ autoDebug = config.AutoDebugTries
200
+ // Only set autoDebug if AutoDebug is enabled in config
201
+ if !config.AutoDebug {
202
+ autoDebug = 0
203
+ }
204
+ }
205
+
206
+ if !cmd.Flags().Changed("skip-menu") {
207
+ tellSkipMenu = config.SkipChangesMenu
208
+ }
209
+
210
+ // tell command editor is no longer tied to config *unless* it's set to vim or nano
211
+ // otherwise, the flag or EDITOR env var are used
212
+ // config.Editor is now used for mainly for JSON editing (and perhaps other purposes)
213
+ // this is because it's pretty rare to use the editor for writing prompts now rather than the REPL
214
+ if !editorSetByFlag && (config.Editor == shared.EditorTypeVim || config.Editor == shared.EditorTypeNano) {
215
+ editor = config.Editor
216
+ }
217
+
218
+ validatePlanExecFlags(isApply)
219
+ }
220
+
221
+ // AutoDebugValue implements the flag.Value interface
222
+ type autoDebugValue struct {
223
+ value *int
224
+ }
225
+
226
+ func newAutoDebugValue(p *int) *autoDebugValue {
227
+ *p = 0 // Default to 0 (disabled)
228
+ return &autoDebugValue{p}
229
+ }
230
+
231
+ func (f *autoDebugValue) Set(s string) error {
232
+ if s == "" {
233
+ *f.value = defaultAutoDebugTries
234
+ return nil
235
+ }
236
+ v, err := strconv.Atoi(s)
237
+ if err != nil {
238
+ return fmt.Errorf("invalid value for --debug: %v", err)
239
+ }
240
+ if v <= 0 {
241
+ return fmt.Errorf("--debug value must be greater than 0")
242
+ }
243
+ *f.value = v
244
+ return nil
245
+ }
246
+
247
+ func (f *autoDebugValue) String() string {
248
+ if f.value == nil {
249
+ return "0"
250
+ }
251
+ return strconv.Itoa(*f.value)
252
+ }
253
+
254
+ func (f *autoDebugValue) Type() string {
255
+ return "int"
256
+ }
257
+
258
+ // EditorValue implements the flag.Value interface
259
+ type editorValue struct {
260
+ value *string
261
+ }
262
+
263
+ func newEditorValue(p *string) *editorValue {
264
+ *p = defaultEditor
265
+ return &editorValue{p}
266
+ }
267
+
268
+ func (f *editorValue) Set(s string) error {
269
+ if s == "" {
270
+ *f.value = defaultEditor
271
+ return nil
272
+ }
273
+ *f.value = s
274
+ editorSetByFlag = true
275
+ return nil
276
+ }
277
+
278
+ func (f *editorValue) String() string {
279
+ if f.value == nil {
280
+ return ""
281
+ }
282
+ return *f.value
283
+ }
284
+
285
+ func (f *editorValue) Type() string {
286
+ return "string"
287
+ }