@megacode/cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +268 -0
- package/dist/adapters/accounts.js +34 -0
- package/dist/adapters/auth/anthropic.js +36 -0
- package/dist/adapters/auth/chatgpt.js +100 -0
- package/dist/adapters/auth/oauth.js +68 -0
- package/dist/adapters/auth/openrouter.js +27 -0
- package/dist/adapters/auth/store.js +25 -0
- package/dist/adapters/git/worktree.js +113 -0
- package/dist/adapters/mcp/config.js +59 -0
- package/dist/adapters/mcp/manager.js +165 -0
- package/dist/adapters/mcp/results.js +21 -0
- package/dist/adapters/mcp/transport.js +19 -0
- package/dist/adapters/project.js +15 -0
- package/dist/adapters/providers/anthropic.js +103 -0
- package/dist/adapters/providers/catalog.js +80 -0
- package/dist/adapters/providers/chatgpt.js +117 -0
- package/dist/adapters/providers/credentials.js +55 -0
- package/dist/adapters/providers/gemini.js +123 -0
- package/dist/adapters/providers/openai.js +128 -0
- package/dist/adapters/providers/registry.js +68 -0
- package/dist/adapters/providers/shared.js +12 -0
- package/dist/adapters/providers/usage.js +95 -0
- package/dist/adapters/settings.js +15 -0
- package/dist/adapters/skills.js +163 -0
- package/dist/adapters/storage.js +30 -0
- package/dist/adapters/tools/files.js +113 -0
- package/dist/adapters/tools/image.js +63 -0
- package/dist/adapters/tools/index.js +26 -0
- package/dist/adapters/tools/output.js +56 -0
- package/dist/adapters/tools/paths.js +5 -0
- package/dist/adapters/tools/questions.js +52 -0
- package/dist/adapters/tools/shell.js +73 -0
- package/dist/adapters/tools/validation.js +34 -0
- package/dist/args.js +30 -0
- package/dist/cli.js +111 -0
- package/dist/composition.js +18 -0
- package/dist/core/agent.js +128 -0
- package/dist/core/conversation.js +37 -0
- package/dist/core/prompts.js +12 -0
- package/dist/core/provider.js +8 -0
- package/dist/core/settings.js +15 -0
- package/dist/core/suggestion.js +29 -0
- package/dist/core/tools.js +10 -0
- package/dist/lib/async.js +6 -0
- package/dist/ui/App.js +218 -0
- package/dist/ui/ApprovalDialog.js +39 -0
- package/dist/ui/ConfigMenu.js +106 -0
- package/dist/ui/Dialog.js +10 -0
- package/dist/ui/DiffLines.js +12 -0
- package/dist/ui/EffortPicker.js +8 -0
- package/dist/ui/Help.js +21 -0
- package/dist/ui/KeyList.js +10 -0
- package/dist/ui/LoginDialog.js +154 -0
- package/dist/ui/McpMenu.js +138 -0
- package/dist/ui/ModelPicker.js +117 -0
- package/dist/ui/PromptInput.js +161 -0
- package/dist/ui/Questionnaire.js +35 -0
- package/dist/ui/Select.js +28 -0
- package/dist/ui/Spinner.js +18 -0
- package/dist/ui/StatusLine.js +19 -0
- package/dist/ui/TextField.js +22 -0
- package/dist/ui/Transcript.js +40 -0
- package/dist/ui/WorktreeMenu.js +83 -0
- package/dist/ui/autocomplete.js +6 -0
- package/dist/ui/commands.js +117 -0
- package/dist/ui/diff.js +130 -0
- package/dist/ui/format.js +138 -0
- package/dist/ui/history.js +9 -0
- package/dist/ui/hooks/useAgentSession.js +34 -0
- package/dist/ui/hooks/usePromptSuggestion.js +23 -0
- package/dist/ui/hooks/useStreamedText.js +38 -0
- package/dist/ui/hooks/useWorktree.js +30 -0
- package/dist/ui/loginFlow.js +37 -0
- package/dist/ui/mcpWizard.js +18 -0
- package/dist/ui/plain.js +60 -0
- package/dist/ui/session.js +168 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michal Jach
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# megacode
|
|
2
|
+
|
|
3
|
+
A minimal coding agent harness for multiple providers and models. It uses the official Anthropic, OpenAI and Google GenAI SDKs. Requires Node ≥ 22.14.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install -g @megacode/cli
|
|
7
|
+
|
|
8
|
+
megacode # interactive TUI
|
|
9
|
+
megacode "fix the failing test" # one-shot, plain output
|
|
10
|
+
cat error.log | megacode "why does this fail?"
|
|
11
|
+
megacode -m openai:gpt-5
|
|
12
|
+
megacode -m gemini:gemini-2.5-pro
|
|
13
|
+
megacode -m ollama:qwen3:8b
|
|
14
|
+
megacode -m openrouter:anthropic/claude-sonnet-5
|
|
15
|
+
megacode -w # work in a new git worktree
|
|
16
|
+
megacode -w fix-auth "fix login" # named worktree, one-shot
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Providers
|
|
20
|
+
|
|
21
|
+
Run `/login` to connect a provider. Where a provider supports browser sign-in, it's offered first:
|
|
22
|
+
|
|
23
|
+
| Provider | Sign-in options |
|
|
24
|
+
| ---------- | ------------------------------------------------------------------------------- |
|
|
25
|
+
| OpenAI | **Sign in with ChatGPT** (uses your Plus/Pro/Business plan), or an API key |
|
|
26
|
+
| OpenRouter | **Sign in with OpenRouter** (OAuth; creates a key for megacode), or an API key |
|
|
27
|
+
| Anthropic | API key, or **Anthropic CLI** (`ant auth login`, which the SDK picks up) |
|
|
28
|
+
| Others | API key, or a server URL for Ollama / LM Studio / custom endpoints |
|
|
29
|
+
|
|
30
|
+
Credentials are verified (by fetching the model list) and saved to `~/.megacode/auth.json` (mode 600). ChatGPT tokens refresh automatically. `/logout` removes saved credentials. On first run with nothing configured, the login dialog opens by itself. An environment variable, if set, takes precedence over anything saved. Set `$BROWSER` to control how sign-in URLs are opened; the URL is also shown in the dialog.
|
|
31
|
+
|
|
32
|
+
"Sign in with ChatGPT" uses the same OAuth client and backend as OpenAI's Codex CLI. That backend isn't a documented public API, so it may change without notice. Claude Pro/Max subscriptions can't be used: Anthropic doesn't permit third-party tools to use Claude.ai logins.
|
|
33
|
+
|
|
34
|
+
`/model` lists every model from every connected provider, fetched live. Type to filter, then press enter. Anything unlisted can be used as `provider:model`. The last model you pick is remembered.
|
|
35
|
+
|
|
36
|
+
| Provider | Env | Notes |
|
|
37
|
+
| ------------ | ----------------------------------------- | -------------------------------- |
|
|
38
|
+
| `anthropic` | `ANTHROPIC_API_KEY` | default: `claude-opus-5` |
|
|
39
|
+
| `openai` | `OPENAI_API_KEY` | default: `gpt-5` |
|
|
40
|
+
| `gemini` | `GEMINI_API_KEY` | default: `gemini-2.5-pro` |
|
|
41
|
+
| `openrouter` | `OPENROUTER_API_KEY` | OpenAI-compatible |
|
|
42
|
+
| `groq` | `GROQ_API_KEY` | OpenAI-compatible |
|
|
43
|
+
| `deepseek` | `DEEPSEEK_API_KEY` | OpenAI-compatible |
|
|
44
|
+
| `ollama` | – | `localhost:11434` |
|
|
45
|
+
| `lmstudio` | – | `localhost:1234` |
|
|
46
|
+
| `compat` | `OPENAI_COMPAT_BASE_URL`, `OPENAI_COMPAT_API_KEY` | any other OpenAI-compatible server |
|
|
47
|
+
|
|
48
|
+
`MEGACODE_MODEL` overrides the default model. Switching models mid-conversation keeps the history. `MEGACODE_CONFIG_DIR` moves everything megacode saves (credentials, settings, history, MCP servers) out of `~/.megacode`.
|
|
49
|
+
|
|
50
|
+
## Interactive UI
|
|
51
|
+
|
|
52
|
+
When the model needs clarification, it can call `ask_questions` to open an interactive questionnaire (up to eight questions). Choose a suggested answer with arrow keys and Enter, or choose **Other** to type your own; questions without options accept text directly. Review all answers before submitting, or start over. **Esc** or **Ctrl+C** cancels and interrupts the turn without submitting partial answers. Questions always require your input, even in bypass mode. In one-shot/plain mode, the tool tells the model to ask in text instead of waiting for an interactive form.
|
|
53
|
+
|
|
54
|
+
Code changes and code blocks look like Claude Code's: edits show one line-number column with removed lines on red, added lines on green (the changed words brighter) and highlighted context; new files list their lines. Highlighting uses Shiki's TextMate grammars with Claude Code's Monokai-style colors; grammars load in the background, and unknown languages and Markdown stay plain.
|
|
55
|
+
|
|
56
|
+
Successful `edit_file` and `write_file` calls show persistent, syntax-highlighted diffs in the conversation in every permission mode, including automatically accepted edits. Previews include old/new line numbers, nearby context, and red `-` / green `+` markers. Ask-mode approvals use the same preview; overwriting a file shows both removals and additions. Long previews are explicitly truncated to 60 diff lines and 240 characters per source line; very large or expensive diffs show an omission notice. These are interactive previews of built-in file tools, not a live Git diff viewer (shell/MCP edits aren't tracked).
|
|
57
|
+
|
|
58
|
+
| Key | Action |
|
|
59
|
+
| -------------------------- | ------------------------------------------------------------- |
|
|
60
|
+
| `enter` | send (while a turn runs, the message is queued) |
|
|
61
|
+
| `ctrl+s` | send queued messages now (interrupts the running turn) |
|
|
62
|
+
| `\` + `enter`, `option+enter` | newline |
|
|
63
|
+
| `↑` / `↓` | prompt history (saved in `~/.megacode/history.json`) |
|
|
64
|
+
| `/` | commands: `/model`, `/effort`, `/config`, `/mcp`, `/skills`, `/worktree`, `/login`, `/logout`, `/clear`, `/usage`, `/help`, `/exit` |
|
|
65
|
+
| `?` | shortcut help |
|
|
66
|
+
| `esc` | interrupt the running turn, or clear the input |
|
|
67
|
+
| `shift+tab` | cycle permission mode: ask → accept edits → bypass (default) |
|
|
68
|
+
| `ctrl+a` `ctrl+e` `ctrl+u` `ctrl+k` `ctrl+w` | readline-style editing |
|
|
69
|
+
| `ctrl+c` | interrupt, clear input, or exit (press twice) |
|
|
70
|
+
|
|
71
|
+
## Settings
|
|
72
|
+
|
|
73
|
+
`/effort` opens an effort picker, or use `/effort low`, `/effort medium`, `/effort high`, or `/effort default` directly. The selection is saved to `~/.megacode/settings.json` and applies starting with the next turn (including future sessions). `default` preserves provider defaults (medium for ChatGPT). Explicit levels use OpenAI's reasoning effort, Anthropic's output effort, or Gemini's thinking level (Gemini 3) / token budget (1,024 / 8,192 / 24,576 for older models). Not every model or compatible server supports these controls or every level; use `default` if the provider rejects the setting.
|
|
74
|
+
|
|
75
|
+
`/config` opens a settings menu; changes apply immediately and are saved to `~/.megacode/settings.json`.
|
|
76
|
+
|
|
77
|
+
| Setting | Default | Options |
|
|
78
|
+
| -------------------------- | --------- | --------------------------------------------- |
|
|
79
|
+
| Model | – | opens the model picker |
|
|
80
|
+
| Permission mode | bypass | ask · accept edits · bypass (also the session's current mode) |
|
|
81
|
+
| Max steps per turn | 50 | 25 · 50 · 100 · 200 |
|
|
82
|
+
| Shell command timeout | 2m | 30s · 2m · 5m · 10m |
|
|
83
|
+
| Max tool output | 12k chars | 6k · 12k · 30k · 100k |
|
|
84
|
+
| Load AGENTS.md / CLAUDE.md | on | on · off |
|
|
85
|
+
| Worktree base | default branch | default branch (origin/HEAD) · current commit |
|
|
86
|
+
| Save prompt history | on | on · off |
|
|
87
|
+
| Prompt autocomplete | on | on · off |
|
|
88
|
+
|
|
89
|
+
Prompt autocomplete suggests a next prompt based on the current conversation and recent tool results, displayed as dimmed inline text after a turn finishes. **Tab** accepts without sending; **Enter** sends only text you've entered or accepted. Typing a different prompt hides the suggestion. Suggestions use an additional, tool-free request to the selected model (normal provider costs apply, tokens count toward `/usage`); they never execute actions or change conversation history. Failures silently leave the prompt unchanged. Toggle **Prompt autocomplete** in `/config`, or set `"promptAutocomplete": false` in `~/.megacode/settings.json` to disable requests and suggestions. Slash-command completion remains available independently.
|
|
90
|
+
|
|
91
|
+
## Skills
|
|
92
|
+
|
|
93
|
+
Skills are reusable instructions in a `SKILL.md` file, optionally accompanied by scripts, references, or other assets. Install from a local directory or a GitHub repository:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
megacode skills install ./my-skill
|
|
97
|
+
megacode skills install owner/repository
|
|
98
|
+
megacode skills install https://github.com/owner/repository --global
|
|
99
|
+
megacode skills list
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
In the TUI, use `/skills` to list skills and `/skills install <source> [--global]` to install them. Local paths containing spaces can be quoted. No provider login is required for the standalone CLI commands.
|
|
103
|
+
|
|
104
|
+
- Installs go to `.megacode/skills/<name>` in the current working directory, or `~/.megacode/skills/<name>` with `--global`. Project skills override global skills with the same name. Worktrees use their own working directory's skills.
|
|
105
|
+
- A source can be a single skill directory or a repository containing multiple skills; all discovered skills are installed. GitHub installs require Git and clone the default branch. Branch/subdirectory URLs and non-GitHub remotes aren't supported. Private repositories require existing Git credentials; installation doesn't prompt for login.
|
|
106
|
+
- Installed metadata is available starting with the next turn. The agent reads a skill's full instructions only when relevant or requested by name. `/skills` also reports invalid installed skills, which are skipped during discovery.
|
|
107
|
+
- Existing installs are never overwritten. To remove or reinstall a skill, remove its installation directory first. Installs are copies, not links; source changes aren't automatically synced.
|
|
108
|
+
- **Only install skills you trust.** Installation copies files but never runs skill scripts. Skills can instruct the agent to run commands later, under the session's normal permissions. Symlinks and special files are rejected; `.git`, `node_modules`, and `.megacode` are excluded. Each skill is limited to 50 MiB and 10,000 entries.
|
|
109
|
+
|
|
110
|
+
A skill needs YAML frontmatter with a unique lowercase, hyphenated name (up to 64 characters) and a description (up to 1,024 characters):
|
|
111
|
+
|
|
112
|
+
```markdown
|
|
113
|
+
---
|
|
114
|
+
name: review-tests
|
|
115
|
+
description: Review test coverage and suggest missing regression tests.
|
|
116
|
+
---
|
|
117
|
+
Read the changed code and its tests. Identify missing regression coverage.
|
|
118
|
+
Consult references/checklist.md when needed.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## MCP servers
|
|
122
|
+
|
|
123
|
+
`/mcp` lists your [MCP](https://modelcontextprotocol.io) servers with their status and tools, and lets you add, reconnect, disable or remove them. Servers are saved in `~/.megacode/mcp.json`, in the same format Claude Code and Claude Desktop use, so existing entries can be pasted in:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"mcpServers": {
|
|
128
|
+
"filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"] },
|
|
129
|
+
"github": { "command": "github-mcp-server", "args": ["stdio"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "…" } },
|
|
130
|
+
"docs": { "type": "http", "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer …" } }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- Transports: `stdio` (local command; it inherits your environment plus `env`), `http` (streamable HTTP) and `sse`. Add `"disabled": true` to keep a server configured but off.
|
|
136
|
+
- Servers connect in the background at startup; failures are reported and shown in `/mcp`. Tools that servers add or remove while running are picked up.
|
|
137
|
+
- Tools appear to the model as `mcp__<server>__<tool>` and go through the same approval as shell commands (asked in ask / accept-edits mode, automatic in bypass). Server instructions are added to the system prompt.
|
|
138
|
+
- Not supported yet: OAuth sign-in for remote servers (use a header with a token), project-level `.mcp.json`, and MCP resources and prompts.
|
|
139
|
+
|
|
140
|
+
## Worktrees
|
|
141
|
+
|
|
142
|
+
Like Claude Code, megacode can work in a separate git worktree so the agent's changes stay off your checkout. Worktrees live in `<repo>/.megacode/worktrees/<name>` (hidden from `git status`) on a branch named `worktree-<name>`, branched from the remote's default branch or your current commit (see `/config`).
|
|
143
|
+
|
|
144
|
+
- `megacode -w [name]` starts in a worktree, creating it if needed (random name if omitted). The argument after `-w` is taken as the name if it looks like one (letters, digits, `-`, `_`); otherwise it's part of the prompt.
|
|
145
|
+
- `/worktree` opens a menu to create a worktree, switch between them, or return to the main checkout (keeping or removing the worktree). `/worktree <name>` switches directly.
|
|
146
|
+
- Exiting while in a worktree asks whether to keep it or remove it along with its branch, showing any uncommitted files and unmerged commits that removal would discard.
|
|
147
|
+
- In one-shot mode there's no one to ask: a worktree created by that run and left untouched is removed; anything else is kept.
|
|
148
|
+
|
|
149
|
+
## Tools
|
|
150
|
+
|
|
151
|
+
`read_file`, `view_image`, `write_file`, `edit_file`, `bash`, `list_files`, `grep`. `view_image` sends local PNG, JPEG, GIF, or WebP files (up to 5 MiB) to a vision-capable model, so you can provide a screenshot path without attaching it manually. Tools run without asking by default (bypass mode; change it in `/config`). `-a/--ask` starts in ask mode, where writes, edits and shell commands open an approval dialog: yes, always for this session, or no (esc), which stops the turn so you can redirect. In one-shot mode with `-a` and no terminal, approvals are refused. If `AGENTS.md` or `CLAUDE.md` exists in the working directory, it is added to the system prompt (unless turned off in `/config`).
|
|
152
|
+
|
|
153
|
+
### Token-efficient context
|
|
154
|
+
|
|
155
|
+
File reads default to 200 lines and a 12,000-character budget, with an exact continuation offset. Lines are never silently cut (a single oversized line can exceed the budget). Scope searches and read only the ranges you need.
|
|
156
|
+
|
|
157
|
+
Long shell, search, listing and MCP results show the first and last halves of the "Max tool output" budget (12,000 characters by default, so 6,000 each; see `/config`). Full output is saved in a private `megacode-output-*` directory under the OS temporary directory, with a path the agent can read/search. Exit status stays visible, and a non-zero exit marks the result as an error. These logs may contain sensitive command output; remove them when no longer needed (they are not automatically deleted by megacode). Listings capped at 1,000 entries explicitly request a narrower glob.
|
|
158
|
+
|
|
159
|
+
Conversation history, project instructions, and provider-native reasoning/signatures are retained; there is no lossy automatic history summarization. Existing provider prompt caching remains enabled where supported.
|
|
160
|
+
|
|
161
|
+
## Layout
|
|
162
|
+
|
|
163
|
+
The code follows a ports-and-adapters layout with one dependency rule: `core/` imports nothing outside itself, `adapters/` implement core's ports, and `ui/` sits on top. `composition.ts` is the only place that wires adapters into the core.
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
src/
|
|
167
|
+
cli.tsx entry: flags, TUI or plain mode
|
|
168
|
+
args.ts command-line parsing
|
|
169
|
+
composition.ts composition root: builds the Agent from adapters
|
|
170
|
+
core/ domain and use cases; no SDKs, I/O or UI
|
|
171
|
+
agent.ts agent loop: model turn → run tools → repeat (emits events)
|
|
172
|
+
conversation.ts provider-neutral message format
|
|
173
|
+
provider.ts Provider port, turn request/result, effort, "provider:model" specs
|
|
174
|
+
tools.ts Tool and ToolSource ports, approvals, questionnaires
|
|
175
|
+
settings.ts settings, defaults, permission modes
|
|
176
|
+
prompts.ts system prompt composition
|
|
177
|
+
suggestion.ts next-prompt suggestions
|
|
178
|
+
adapters/
|
|
179
|
+
storage.ts JSON files in the config folder (~/.megacode or $MEGACODE_CONFIG_DIR)
|
|
180
|
+
settings.ts settings.json
|
|
181
|
+
project.ts working directory and AGENTS.md / CLAUDE.md for the system prompt
|
|
182
|
+
accounts.ts /login and /logout use cases: verify, save, remove credentials
|
|
183
|
+
providers/
|
|
184
|
+
catalog.ts known providers and OpenAI-compatible presets
|
|
185
|
+
credentials.ts credential precedence (env, saved, ChatGPT, local, ant profile)
|
|
186
|
+
registry.ts provider factory and cache, model lists, model resolution
|
|
187
|
+
anthropic.ts Messages API (streaming)
|
|
188
|
+
openai.ts Chat Completions (streaming), also used for compatible APIs
|
|
189
|
+
chatgpt.ts Responses API adapter for ChatGPT sign-in
|
|
190
|
+
gemini.ts @google/genai (streaming)
|
|
191
|
+
shared.ts tool-argument parsing, fallback call ids, data URLs
|
|
192
|
+
usage.ts /usage: account limits and balances
|
|
193
|
+
auth/
|
|
194
|
+
store.ts auth.json
|
|
195
|
+
oauth.ts PKCE, local callback server, browser launch
|
|
196
|
+
chatgpt.ts Sign in with ChatGPT, token refresh, usage API
|
|
197
|
+
openrouter.ts OpenRouter OAuth
|
|
198
|
+
anthropic.ts Anthropic CLI login and profile detection
|
|
199
|
+
tools/
|
|
200
|
+
index.ts built-in ToolSource: lookup, validation, error handling
|
|
201
|
+
validation.ts argument checks from each tool's JSON schema
|
|
202
|
+
files.ts read / write / edit / list files
|
|
203
|
+
image.ts view_image: bounded read, format detection
|
|
204
|
+
shell.ts bash, grep
|
|
205
|
+
questions.ts ask_questions tool and input validation
|
|
206
|
+
output.ts output budgets, temp-file spill, paged file reads
|
|
207
|
+
mcp/
|
|
208
|
+
manager.ts MCP connections, offered to the agent as a ToolSource
|
|
209
|
+
config.ts mcp.json, server drafts, command-line splitting
|
|
210
|
+
transport.ts stdio / HTTP / SSE transports
|
|
211
|
+
results.ts tool results as text
|
|
212
|
+
git/worktree.ts git worktree create / list / status / remove
|
|
213
|
+
lib/async.ts withTimeout
|
|
214
|
+
ui/ Ink (React) TUI and plain output
|
|
215
|
+
App.tsx layout, dialogs, keyboard shortcuts, command context
|
|
216
|
+
commands.ts slash command registry (name, description, handler)
|
|
217
|
+
session.ts AgentSession: turns, queue, interrupts, approvals, questionnaires (no React)
|
|
218
|
+
hooks/
|
|
219
|
+
useAgentSession.ts React binding for AgentSession
|
|
220
|
+
useWorktree.ts entering and leaving worktrees
|
|
221
|
+
useStreamedText.ts streamed-text buffering
|
|
222
|
+
usePromptSuggestion.ts
|
|
223
|
+
plain.ts one-shot / piped output
|
|
224
|
+
Transcript.tsx transcript items, streamed text, diff previews
|
|
225
|
+
PromptInput.tsx multi-line editor, history, slash menu
|
|
226
|
+
StatusLine.tsx mode, cwd, model, tokens
|
|
227
|
+
ApprovalDialog.tsx tool permission prompt
|
|
228
|
+
ModelPicker.tsx searchable live model list
|
|
229
|
+
LoginDialog.tsx provider login / logout
|
|
230
|
+
loginFlow.ts /login steps, back navigation, error messages
|
|
231
|
+
ConfigMenu.tsx /config settings menu
|
|
232
|
+
EffortPicker.tsx /effort menu
|
|
233
|
+
WorktreeMenu.tsx /worktree menu, exit prompt
|
|
234
|
+
McpMenu.tsx /mcp server list, add wizard, details
|
|
235
|
+
mcpWizard.ts /mcp add wizard steps and validation
|
|
236
|
+
Questionnaire.tsx ask_questions form
|
|
237
|
+
Dialog.tsx the frame every dialog uses
|
|
238
|
+
Select.tsx, TextField.tsx, KeyList.tsx, Spinner.tsx, Help.tsx small building blocks
|
|
239
|
+
syntax.ts syntax highlighting (Shiki grammars, Claude Code colors)
|
|
240
|
+
diff.ts diff model: rows, word-level changes, plain-text rendering
|
|
241
|
+
DiffLines.tsx diff rows in the TUI
|
|
242
|
+
format.ts Markdown rendering, tool labels, previews
|
|
243
|
+
test/ node:test suites (npm test); e2e.test.mjs runs the CLI against a fake model server
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Conversation history is stored in a neutral format (`core/conversation.ts`). Each assistant turn also keeps the provider's native content. That content is sent back verbatim to the same provider, so Anthropic thinking blocks and Gemini thought signatures are preserved. When you switch providers, the next one gets the neutral text and tool calls instead.
|
|
247
|
+
|
|
248
|
+
Tools return structured results; a file edit reports the change (`file`, `before`, `after`), and only the UI turns it into a colored diff, so nothing display-only reaches the model.
|
|
249
|
+
|
|
250
|
+
To add a provider, implement the `Provider` port (`core/provider.ts`) in `src/adapters/providers/`, add it to `catalog.ts`, and construct it in `registry.ts`. To add a tool source, implement `ToolSource` (`core/tools.ts`) and combine it in `composition.ts`.
|
|
251
|
+
|
|
252
|
+
## Harness benchmark
|
|
253
|
+
|
|
254
|
+
See [bench/README.md](bench/README.md) for fixed coding tasks, independent grading, same-model comparisons with other CLI harnesses, and local megacode results.
|
|
255
|
+
|
|
256
|
+
```sh
|
|
257
|
+
npm run bench -- --model openai:gpt-6-astra --repeats 5
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Development
|
|
261
|
+
|
|
262
|
+
```sh
|
|
263
|
+
npm install
|
|
264
|
+
npm start # runs src/cli.tsx via tsx
|
|
265
|
+
npm test # unit and UI rendering tests in test/
|
|
266
|
+
npm run typecheck
|
|
267
|
+
npm run build # compiles to dist/, which is what gets published
|
|
268
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { withTimeout } from "../lib/async.js";
|
|
2
|
+
import { removeCredentials, saveCredentials } from "./auth/store.js";
|
|
3
|
+
import { envKeyName, providerInfo } from "./providers/catalog.js";
|
|
4
|
+
import { createProvider, resetProvider } from "./providers/registry.js";
|
|
5
|
+
// Account use cases behind /login and /logout. The UI collects input; this decides what gets stored.
|
|
6
|
+
const VERIFY_TIMEOUT = 20_000;
|
|
7
|
+
/** Checks a key and/or endpoint by listing models, then saves it. Throws (saving nothing) if it doesn't work. */
|
|
8
|
+
export async function verifyAndSaveKey(name, input) {
|
|
9
|
+
const apiKey = input.apiKey || undefined;
|
|
10
|
+
const baseURL = input.baseURL || undefined;
|
|
11
|
+
await withTimeout(createProvider(name, { apiKey, baseURL, source: "saved" }).listModels(), VERIFY_TIMEOUT);
|
|
12
|
+
// Don't pin the default endpoint, so a later change to the default still applies.
|
|
13
|
+
saveCredentials(name, { apiKey, baseURL: baseURL !== providerInfo(name).baseURL ? baseURL : undefined });
|
|
14
|
+
resetProvider(name);
|
|
15
|
+
}
|
|
16
|
+
/** "Sign in with ChatGPT" replaces any saved OpenAI API key. */
|
|
17
|
+
export function saveChatGPTLogin(tokens) {
|
|
18
|
+
saveCredentials("openai", { chatgpt: tokens });
|
|
19
|
+
resetProvider("openai");
|
|
20
|
+
}
|
|
21
|
+
/** With credentials in place, how many models the provider offers. Throws if they don't work. */
|
|
22
|
+
export async function countModels(name) {
|
|
23
|
+
resetProvider(name);
|
|
24
|
+
return (await withTimeout(createProvider(name).listModels(), VERIFY_TIMEOUT)).length;
|
|
25
|
+
}
|
|
26
|
+
/** Removes saved credentials and says what happened, including an env var that still applies. */
|
|
27
|
+
export function logout(name) {
|
|
28
|
+
if (!removeCredentials(name))
|
|
29
|
+
return { ok: false, message: `No saved credentials for ${name}.` };
|
|
30
|
+
resetProvider(name);
|
|
31
|
+
const info = providerInfo(name);
|
|
32
|
+
const env = envKeyName(info);
|
|
33
|
+
return { ok: true, message: `Removed saved credentials for ${info.label}${env ? ` ($${env} is still set in your environment)` : ""}.` };
|
|
34
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/** Thrown by loginAnthropicCLI when the `ant` binary isn't on PATH. */
|
|
6
|
+
export class AntNotInstalledError extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super("The Anthropic CLI isn't installed. Install it with `brew install anthropics/tap/ant`, or paste an API key.");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const antConfigDir = () => process.env.ANTHROPIC_CONFIG_DIR ??
|
|
12
|
+
(process.platform === "win32" ? path.join(process.env.APPDATA ?? "", "Anthropic") : path.join(os.homedir(), ".config", "anthropic"));
|
|
13
|
+
/** True if `ant auth login` has stored credentials the Anthropic SDK will pick up. */
|
|
14
|
+
export function hasAntProfile() {
|
|
15
|
+
const dir = path.join(antConfigDir(), "credentials");
|
|
16
|
+
try {
|
|
17
|
+
return existsSync(dir) && readdirSync(dir).some((f) => f.endsWith(".json"));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Runs `ant auth login` (Anthropic CLI browser sign-in). The SDK reads the resulting profile,
|
|
25
|
+
* so megacode stores nothing itself. Each line of the CLI's output goes to `onOutput`.
|
|
26
|
+
*/
|
|
27
|
+
export function loginAnthropicCLI(onOutput, signal) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const child = spawn("ant", ["auth", "login"], { stdio: ["ignore", "pipe", "pipe"], signal });
|
|
30
|
+
const onData = (d) => d.toString().split("\n").filter(Boolean).forEach(onOutput);
|
|
31
|
+
child.stdout.on("data", onData);
|
|
32
|
+
child.stderr.on("data", onData);
|
|
33
|
+
child.on("error", (e) => reject(e.code === "ENOENT" ? new AntNotInstalledError() : e.name === "AbortError" ? new Error("Sign-in cancelled") : e));
|
|
34
|
+
child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`ant auth login exited with code ${code}`))));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { callbackServer, jwtClaims, openBrowser, pkce } from "./oauth.js";
|
|
2
|
+
import { saveCredentials, savedCredentials } from "./store.js";
|
|
3
|
+
// "Sign in with ChatGPT": the OAuth client and backend used by OpenAI's Codex CLI
|
|
4
|
+
// (github.com/openai/codex, codex-rs/login). Requests are billed to the user's ChatGPT plan.
|
|
5
|
+
// This backend is not a documented public API and may change.
|
|
6
|
+
// The env overrides exist for testing against a local mock.
|
|
7
|
+
const ISSUER = process.env.MEGACODE_CHATGPT_ISSUER ?? "https://auth.openai.com";
|
|
8
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
9
|
+
const PORT = 1455; // the redirect URI registered for this client
|
|
10
|
+
const REDIRECT_URI = `http://127.0.0.1:${PORT}/auth/callback`;
|
|
11
|
+
export const CHATGPT_BASE_URL = process.env.MEGACODE_CHATGPT_BASE_URL ?? "https://chatgpt.com/backend-api/codex";
|
|
12
|
+
const ORIGINATOR = "codex_cli_rs";
|
|
13
|
+
/** Headers the ChatGPT backend expects on every request. */
|
|
14
|
+
export const chatGPTHeaders = (t) => ({ "ChatGPT-Account-ID": t.accountId, originator: ORIGINATOR });
|
|
15
|
+
function toTokens(res, prev) {
|
|
16
|
+
const id = res.id_token ? jwtClaims(res.id_token) : {};
|
|
17
|
+
const access = jwtClaims(res.access_token);
|
|
18
|
+
const auth = id["https://api.openai.com/auth"] ?? access["https://api.openai.com/auth"] ?? {};
|
|
19
|
+
return {
|
|
20
|
+
access: res.access_token,
|
|
21
|
+
refresh: res.refresh_token ?? prev?.refresh ?? "",
|
|
22
|
+
expires: access.exp ? access.exp * 1000 : Date.now() + (res.expires_in ?? 3600) * 1000,
|
|
23
|
+
accountId: auth.chatgpt_account_id ?? prev?.accountId ?? "",
|
|
24
|
+
email: id.email ?? id["https://api.openai.com/profile"]?.email ?? prev?.email,
|
|
25
|
+
plan: auth.chatgpt_plan_type ?? prev?.plan,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function tokenRequest(body, format) {
|
|
29
|
+
const res = await fetch(`${ISSUER}/oauth/token`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded" },
|
|
32
|
+
body: format === "json" ? JSON.stringify(body) : new URLSearchParams(body).toString(),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok)
|
|
35
|
+
throw new Error(`Token request failed (${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
36
|
+
return res.json();
|
|
37
|
+
}
|
|
38
|
+
function authorizeUrl(challenge, state) {
|
|
39
|
+
const url = new URL(`${ISSUER}/oauth/authorize`);
|
|
40
|
+
const params = {
|
|
41
|
+
response_type: "code",
|
|
42
|
+
client_id: CLIENT_ID,
|
|
43
|
+
redirect_uri: REDIRECT_URI,
|
|
44
|
+
scope: "openid profile email offline_access",
|
|
45
|
+
code_challenge: challenge,
|
|
46
|
+
code_challenge_method: "S256",
|
|
47
|
+
state,
|
|
48
|
+
id_token_add_organizations: "true",
|
|
49
|
+
codex_cli_simplified_flow: "true",
|
|
50
|
+
originator: ORIGINATOR,
|
|
51
|
+
};
|
|
52
|
+
for (const [k, v] of Object.entries(params))
|
|
53
|
+
url.searchParams.set(k, v);
|
|
54
|
+
return url.toString();
|
|
55
|
+
}
|
|
56
|
+
/** Browser sign-in with a ChatGPT account. `onUrl` gets the URL in case the browser doesn't open. */
|
|
57
|
+
export async function loginChatGPT(onUrl, signal) {
|
|
58
|
+
const { verifier, challenge, state } = pkce();
|
|
59
|
+
const server = await callbackServer({ port: PORT, path: "/auth/callback", state, signal });
|
|
60
|
+
try {
|
|
61
|
+
const url = authorizeUrl(challenge, state);
|
|
62
|
+
onUrl(url);
|
|
63
|
+
openBrowser(url);
|
|
64
|
+
const code = await server.code;
|
|
65
|
+
const tokens = toTokens(await tokenRequest({ grant_type: "authorization_code", client_id: CLIENT_ID, code, redirect_uri: REDIRECT_URI, code_verifier: verifier }, "form"));
|
|
66
|
+
if (!tokens.accountId)
|
|
67
|
+
throw new Error("This account has no ChatGPT workspace. Use an API key instead.");
|
|
68
|
+
return tokens;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
server.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Current tokens, refreshed (and re-saved) when they're about to expire or `force` is set. */
|
|
75
|
+
export async function chatGPTTokens(force = false) {
|
|
76
|
+
const saved = savedCredentials("openai");
|
|
77
|
+
const tokens = saved?.chatgpt;
|
|
78
|
+
if (!tokens)
|
|
79
|
+
throw new Error("Not signed in with ChatGPT. Run /login.");
|
|
80
|
+
if (!force && tokens.expires > Date.now() + 60_000)
|
|
81
|
+
return tokens;
|
|
82
|
+
const refreshed = toTokens(await tokenRequest({ client_id: CLIENT_ID, grant_type: "refresh_token", refresh_token: tokens.refresh }, "json"), tokens);
|
|
83
|
+
saveCredentials("openai", { ...saved, chatgpt: refreshed });
|
|
84
|
+
return refreshed;
|
|
85
|
+
}
|
|
86
|
+
/** Live subscription limits from the same backend used by Codex. */
|
|
87
|
+
export async function chatGPTUsage() {
|
|
88
|
+
const signal = AbortSignal.timeout(15_000);
|
|
89
|
+
const url = `${CHATGPT_BASE_URL.replace(/\/codex\/?$/, "")}/wham/usage`;
|
|
90
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
91
|
+
const t = await chatGPTTokens(attempt > 0);
|
|
92
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${t.access}`, ...chatGPTHeaders(t) }, signal });
|
|
93
|
+
if (res.status === 401 && attempt === 0)
|
|
94
|
+
continue; // token revoked early: refresh once
|
|
95
|
+
if (!res.ok)
|
|
96
|
+
throw new Error(`Usage request failed (HTTP ${res.status}).${res.status === 401 || res.status === 403 ? " Run /login to update credentials." : ""}`);
|
|
97
|
+
return res.json();
|
|
98
|
+
}
|
|
99
|
+
throw new Error("Unable to fetch ChatGPT usage.");
|
|
100
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
const b64url = (buf) => buf.toString("base64url");
|
|
5
|
+
export function pkce() {
|
|
6
|
+
const verifier = b64url(randomBytes(32));
|
|
7
|
+
return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()), state: b64url(randomBytes(16)) };
|
|
8
|
+
}
|
|
9
|
+
export function openBrowser(url) {
|
|
10
|
+
// $BROWSER is the usual override (and lets headless setups print or handle the URL).
|
|
11
|
+
const [cmd, args] = process.env.BROWSER
|
|
12
|
+
? [process.env.BROWSER, [url]]
|
|
13
|
+
: process.platform === "darwin"
|
|
14
|
+
? ["open", [url]]
|
|
15
|
+
: process.platform === "win32"
|
|
16
|
+
? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]]
|
|
17
|
+
: ["xdg-open", [url]];
|
|
18
|
+
try {
|
|
19
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => { }).unref();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// the URL is also shown in the UI, so the user can open it by hand
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, (char) => ({
|
|
26
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
27
|
+
})[char]);
|
|
28
|
+
const PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>${escapeHtml(title)}</title><body style="font:16px system-ui;display:grid;place-items:center;height:90vh"><div><h2>${escapeHtml(title)}</h2><p>${escapeHtml(body)}</p></div>`;
|
|
29
|
+
/**
|
|
30
|
+
* Local HTTP server that receives the OAuth redirect (`?code=…&state=…`) on 127.0.0.1.
|
|
31
|
+
* Call `close()` when done; `code` rejects on error, state mismatch or abort.
|
|
32
|
+
*/
|
|
33
|
+
export async function callbackServer(opts) {
|
|
34
|
+
let resolveCode;
|
|
35
|
+
let rejectCode;
|
|
36
|
+
const code = new Promise((res, rej) => ((resolveCode = res), (rejectCode = rej)));
|
|
37
|
+
code.catch(() => { }); // handled by the caller
|
|
38
|
+
const server = createServer((req, res) => {
|
|
39
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
40
|
+
if (url.pathname !== opts.path)
|
|
41
|
+
return res.writeHead(404).end();
|
|
42
|
+
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error");
|
|
43
|
+
const got = url.searchParams.get("code");
|
|
44
|
+
if (error || !got || (opts.state && url.searchParams.get("state") !== opts.state)) {
|
|
45
|
+
res.writeHead(400, { "content-type": "text/html" }).end(PAGE("Sign-in failed", error ?? "Invalid response. Try again from megacode."));
|
|
46
|
+
return rejectCode(new Error(error ?? "Invalid OAuth callback"));
|
|
47
|
+
}
|
|
48
|
+
res.writeHead(200, { "content-type": "text/html" }).end(PAGE("Signed in ✔", "You can close this tab and return to megacode."));
|
|
49
|
+
resolveCode(got);
|
|
50
|
+
});
|
|
51
|
+
await new Promise((resolve, reject) => {
|
|
52
|
+
server.once("error", (e) => reject(e.code === "EADDRINUSE" ? new Error(`Port ${opts.port} is in use. Close other sign-in windows and retry.`) : e));
|
|
53
|
+
server.listen(opts.port, "127.0.0.1", resolve);
|
|
54
|
+
});
|
|
55
|
+
const port = server.address().port;
|
|
56
|
+
const close = () => server.close();
|
|
57
|
+
opts.signal.addEventListener("abort", () => rejectCode(new Error("Sign-in cancelled")), { once: true });
|
|
58
|
+
return { port, code, close };
|
|
59
|
+
}
|
|
60
|
+
/** Decode a JWT payload without verifying it (we only read our own tokens' claims). */
|
|
61
|
+
export function jwtClaims(token) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { callbackServer, openBrowser, pkce } from "./oauth.js";
|
|
2
|
+
/** OpenRouter's OAuth PKCE flow for apps: returns a regular OpenRouter API key. */
|
|
3
|
+
export async function loginOpenRouter(onUrl, signal) {
|
|
4
|
+
const { verifier, challenge } = pkce();
|
|
5
|
+
const server = await callbackServer({ port: 0, path: "/callback", signal });
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL("https://openrouter.ai/auth");
|
|
8
|
+
url.searchParams.set("callback_url", `http://localhost:${server.port}/callback`);
|
|
9
|
+
url.searchParams.set("code_challenge", challenge);
|
|
10
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
11
|
+
onUrl(url.toString());
|
|
12
|
+
openBrowser(url.toString());
|
|
13
|
+
const code = await server.code;
|
|
14
|
+
const res = await fetch("https://openrouter.ai/api/v1/auth/keys", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "content-type": "application/json" },
|
|
17
|
+
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
|
|
18
|
+
signal,
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok)
|
|
21
|
+
throw new Error(`OpenRouter key exchange failed (${res.status})`);
|
|
22
|
+
return (await res.json()).key;
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
server.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readJson, writeJson } from "../storage.js";
|
|
2
|
+
const FILE = "auth.json";
|
|
3
|
+
const load = () => readJson(FILE, {});
|
|
4
|
+
const save = (auth) => writeJson(FILE, auth, { secret: true });
|
|
5
|
+
export const savedCredentials = (provider) => load()[provider];
|
|
6
|
+
export const savedProviders = () => Object.keys(load());
|
|
7
|
+
/** Replaces a provider's saved credentials; empty credentials remove the entry. */
|
|
8
|
+
export function saveCredentials(provider, creds) {
|
|
9
|
+
const auth = load();
|
|
10
|
+
const entry = Object.fromEntries(Object.entries(creds).filter(([, v]) => v));
|
|
11
|
+
if (Object.keys(entry).length)
|
|
12
|
+
auth[provider] = entry;
|
|
13
|
+
else
|
|
14
|
+
delete auth[provider];
|
|
15
|
+
save(auth);
|
|
16
|
+
}
|
|
17
|
+
/** Returns false if nothing was saved for the provider. */
|
|
18
|
+
export function removeCredentials(provider) {
|
|
19
|
+
const auth = load();
|
|
20
|
+
if (!auth[provider])
|
|
21
|
+
return false;
|
|
22
|
+
delete auth[provider];
|
|
23
|
+
save(auth);
|
|
24
|
+
return true;
|
|
25
|
+
}
|