Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d2964a8
protocol(stream-json): additive v1 events for a desktop client
timothyerwin Aug 12, 2026
79a497f
protocol(stream-json): session resume + first-class attachments
timothyerwin Aug 12, 2026
ac5a977
protocol(stream-json): structured tool, diff, and todos events
timothyerwin Aug 12, 2026
4f317c5
cli: machine-readable models + status JSON for a GUI client
timothyerwin Aug 12, 2026
c920218
desktop: Electron app skeleton — CLI bridge, chat UI, packaging
timothyerwin Aug 12, 2026
0ea1b4e
cli: config get --json for the GUI Settings surface
timothyerwin Aug 12, 2026
3453af3
protocol: document + test the stream-json v1 event surface
timothyerwin Aug 12, 2026
cae76eb
cli: config set/sources + session recent --json for the GUI wizard & …
timothyerwin Aug 12, 2026
217c703
desktop: first-run wizard, native Account menu, session sidebar, BYOK…
timothyerwin Aug 12, 2026
cc7e6fd
desktop: allowlist electron/esbuild build scripts for pnpm 10
timothyerwin Aug 12, 2026
a0f637f
desktop: remember and reopen the last repo, show recents on welcome
timothyerwin Aug 12, 2026
dff7474
desktop: proper design pass on the UI (Codex/Hermes-caliber)
timothyerwin Aug 12, 2026
555c948
desktop: light/dark themes + wordmark + always-on shell + Open Folder
timothyerwin Aug 12, 2026
db61a0e
desktop: use the real matrix MEMCODE brand, stop scattering Open Folder
timothyerwin Aug 12, 2026
6ab0eb3
desktop: drop the top bar, group sidebar by project, memcode app icon
timothyerwin Aug 12, 2026
616299a
cli: generate a short chat title per session (sidebar/session recent)
timothyerwin Aug 12, 2026
56df252
protocol: suppress terminal chrome for structured clients (clean desk…
timothyerwin Aug 12, 2026
c7736a0
desktop: crisp macOS app icon (rounded squircle + brand monogram)
timothyerwin Aug 12, 2026
5f8af42
protocol: set_model command to change the model pin mid-session
timothyerwin Aug 12, 2026
6e52b30
desktop: model picker shows real names + applies to the live session
timothyerwin Aug 12, 2026
a88f7f1
desktop: app icon = the real memcode logo (matrix), rounded corners
timothyerwin Aug 12, 2026
61a1d08
Improve desktop UX and stream protocol
timothyerwin Aug 12, 2026
a3fa875
authflow: brand the CLI login callback page with the memcode wordmark
timothyerwin Aug 13, 2026
5d3c434
authflow: render the live MEMCODE matrix wordmark on the CLI login ca…
timothyerwin Aug 13, 2026
33fd2d9
desktop: spawn the CLI as 'memcode run' (agent verb renamed)
timothyerwin Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: desktop-release

# Builds Memcode Desktop for macOS, Windows, and Linux. The core `memcode` binary
# is compiled from THIS commit and bundled into the app (no downloaded release),
# so Desktop and core share the same protocol version. Signing/notarization run
# automatically when credentials are present; otherwise unsigned dev artifacts.

on:
push:
tags:
- "desktop-v*"
workflow_dispatch:

permissions:
contents: write

jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
goos: darwin
- os: windows-latest
goos: windows
- os: ubuntu-latest
goos: linux
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.23"

- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: desktop/pnpm-lock.yaml

# Compile the core binary from the checked-out commit for host and (on mac)
# both arches, embedding version/commit so About/diagnostics show real values.
- name: Build core binary (unix)
if: matrix.goos != 'windows'
shell: bash
working-directory: .
run: |
mkdir -p desktop/resources/bin
VER="${GITHUB_REF_NAME}"
SHA="$(git rev-parse --short HEAD)"
LDFLAGS="-s -w -X github.com/memcode-ai/memcode/internal/buildinfo.Version=${VER} -X github.com/memcode-ai/memcode/internal/buildinfo.Commit=${SHA}"
CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o desktop/resources/bin/memcode .

- name: Build core binary (windows)
if: matrix.goos == 'windows'
shell: bash
working-directory: .
run: |
mkdir -p desktop/resources/bin
VER="${GITHUB_REF_NAME}"
SHA="$(git rev-parse --short HEAD)"
LDFLAGS="-s -w -X github.com/memcode-ai/memcode/internal/buildinfo.Version=${VER} -X github.com/memcode-ai/memcode/internal/buildinfo.Commit=${SHA}"
CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o desktop/resources/bin/memcode.exe .

- name: Install deps
run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm run typecheck

- name: Build renderer/main/preload
run: pnpm run build

- name: Package (mac, conditional sign + notarize)
if: matrix.goos == 'darwin'
run: pnpm exec electron-builder --mac --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Present only when configured; absent -> unsigned dev artifact.
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}

- name: Package (windows, conditional sign)
if: matrix.goos == 'windows'
run: pnpm exec electron-builder --win --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}

- name: Package (linux)
if: matrix.goos == 'linux'
run: pnpm exec electron-builder --linux --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
123 changes: 123 additions & 0 deletions cmd/gui.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package cmd

import (
"encoding/json"
"os"

"github.com/spf13/cobra"

"github.com/memcode-ai/memcode/catalog"
"github.com/memcode-ai/memcode/internal/buildinfo"
"github.com/memcode-ai/memcode/internal/provider"
"github.com/memcode-ai/memcode/internal/wire"
)

// GUI/machine surface: JSON commands a desktop or SDK client queries so the CLI
// core stays the single source of truth — the model catalog and auth/version
// status. The `--json` flag is accepted (and default) so `memcode models --json`
// reads naturally; these commands only ever emit JSON.

var guiModelsCmd = &cobra.Command{
Use: "models",
Short: "List the supported model catalog as JSON",
Long: "Print the model catalog (window, pricing, vision/pdf/reasoning flags, picker metadata) as JSON. The GUI model picker reads this instead of embedding its own copy.",
RunE: func(cmd *cobra.Command, _ []string) error {
all := catalog.CatalogModels()
if pinnable, _ := cmd.Flags().GetBool("pinnable"); pinnable {
kept := all[:0]
for _, m := range all {
if m.Pinnable {
kept = append(kept, m)
}
}
all = kept
}
return json.NewEncoder(os.Stdout).Encode(all)
},
}

type statusJSON struct {
LoggedIn bool `json:"logged_in"`
TokenSource string `json:"token_source"` // "environment" | a config path | "none"
Endpoint string `json:"endpoint"`
Version string `json:"version"`
Commit string `json:"commit"`
ProtocolVersion string `json:"protocol_version"`
}

var guiStatusCmd = &cobra.Command{
Use: "status",
Short: "Print login/endpoint/version status as JSON",
Long: "Print machine-readable status — login state, gateway endpoint, core version/commit, and the stream-json protocol version — for a GUI Settings/About page.",
RunE: func(_ *cobra.Command, _ []string) error {
provider.LoadDotEnv()
src := provider.APITokenSource()
endpoint := os.Getenv(provider.EnvAPIURL)
if endpoint == "" {
endpoint = provider.DefaultAPIURL
}
return json.NewEncoder(os.Stdout).Encode(statusJSON{
LoggedIn: src != "" && src != "none",
TokenSource: src,
Endpoint: endpoint,
Version: buildinfo.Version,
Commit: buildinfo.Commit,
ProtocolVersion: wire.StreamJSONVersion,
})
},
}

type configJSON struct {
Endpoint string `json:"endpoint"`
TokenSource string `json:"token_source"`
LoggedIn bool `json:"logged_in"`
DefaultModels map[string]string `json:"default_models"` // tier -> model id
BYOK map[string]bool `json:"byok"` // own-key env -> present
}

var guiConfigCmd = &cobra.Command{
Use: "config",
Short: "Read effective configuration as JSON",
Long: "Print the effective configuration a GUI Settings page reads — endpoint, login state, per-tier default models, and which direct-provider BYOK keys are present. Secret values are never emitted, only presence.",
}

var guiConfigGetCmd = &cobra.Command{
Use: "get",
Short: "Print effective configuration as JSON",
RunE: func(_ *cobra.Command, _ []string) error {
provider.LoadDotEnv()
src := provider.APITokenSource()
endpoint := os.Getenv(provider.EnvAPIURL)
if endpoint == "" {
endpoint = provider.DefaultAPIURL
}
byok := map[string]bool{}
for _, env := range provider.OwnKeyEnvs() {
byok[env] = os.Getenv(env) != ""
}
return json.NewEncoder(os.Stdout).Encode(configJSON{
Endpoint: endpoint,
TokenSource: src,
LoggedIn: src != "" && src != "none",
DefaultModels: map[string]string{
string(provider.TierPlanner): provider.DefaultModel(provider.TierPlanner),
string(provider.TierCoder): provider.DefaultModel(provider.TierCoder),
string(provider.TierReviewer): provider.DefaultModel(provider.TierReviewer),
string(provider.TierSynthesizer): provider.DefaultModel(provider.TierSynthesizer),
string(provider.TierClassifier): provider.DefaultModel(provider.TierClassifier),
},
BYOK: byok,
})
},
}

func init() {
guiModelsCmd.Flags().Bool("json", true, "output JSON (default; only format)")
guiModelsCmd.Flags().Bool("pinnable", false, "only models offered in the picker")
guiStatusCmd.Flags().Bool("json", true, "output JSON (default; only format)")
guiConfigGetCmd.Flags().Bool("json", true, "output JSON (default; only format)")
guiConfigCmd.AddCommand(guiConfigGetCmd)
rootCmd.AddCommand(guiModelsCmd)
rootCmd.AddCommand(guiStatusCmd)
rootCmd.AddCommand(guiConfigCmd)
}
158 changes: 158 additions & 0 deletions cmd/gui_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package cmd

import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"

"github.com/memcode-ai/memcode/internal/authflow"
"github.com/memcode-ai/memcode/internal/provider"
"github.com/memcode-ai/memcode/internal/subscription/claudesub"
"github.com/memcode-ai/memcode/internal/subscription/codex"
"github.com/memcode-ai/memcode/internal/subscription/copilot"
)

// The write + detection half of the GUI config surface: `config set` persists a
// backend choice to the global env file (the same store the CLI wizard writes),
// and `config sources` reports what a first-run wizard needs — detected
// subscriptions and whether a backend is already configured. Together with
// `config get` these let a desktop first-run wizard do exactly what the CLI's
// does, without the app reimplementing detection or credential storage.

// settableEnv reports whether key may be written through `config set`. Only the
// backend-choice knobs are allowed: the gateway URL, a custom endpoint, a
// subscription source, and ecosystem-standard BYOK provider keys (*_API_KEY).
// The gateway TOKEN is deliberately excluded — `memcode login` owns it.
func settableEnv(key string) bool {
switch key {
case provider.EnvAPIURL, provider.EnvEndpointURL, provider.EnvCredentialSource:
return true
case provider.EnvAPIToken:
return false
}
return strings.HasSuffix(key, "_API_KEY")
}

var guiConfigSetCmd = &cobra.Command{
Use: "set [KEY=VALUE ...]",
Short: "Write backend configuration to the global env file",
Long: `Persist backend configuration (endpoint, subscription source, or a BYOK
provider key) to the user-global env file (~/.config/memcode/.env), the same
store the first-run wizard writes. Pass KEY=VALUE pairs as arguments, or --stdin
to read KEY=VALUE lines from stdin (use --stdin for secrets so keys never land
in the process argument list). Only backend-choice keys are settable; the
gateway token is managed by ` + "`memcode login`" + `. Emits the keys set as JSON
(never their values).`,
RunE: func(cmd *cobra.Command, args []string) error {
kv := map[string]string{}
add := func(pair string) error {
key, val, ok := strings.Cut(pair, "=")
if !ok {
return fmt.Errorf("expected KEY=VALUE, got %q", pair)
}
key = strings.TrimSpace(key)
if !settableEnv(key) {
return fmt.Errorf("%q is not a settable key (endpoint, subscription source, or *_API_KEY only; the gateway token is managed by `memcode login`)", key)
}
kv[key] = strings.TrimSpace(val)
return nil
}
for _, a := range args {
if err := add(a); err != nil {
return err
}
}
if useStdin, _ := cmd.Flags().GetBool("stdin"); useStdin {
sc := bufio.NewScanner(os.Stdin)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if err := add(line); err != nil {
return err
}
}
if err := sc.Err(); err != nil {
return err
}
}
if len(kv) == 0 {
return fmt.Errorf("nothing to set (pass KEY=VALUE or --stdin)")
}
if err := authflow.SetGlobalEnv(kv); err != nil {
return err
}
keys := make([]string, 0, len(kv))
for k := range kv {
keys = append(keys, k)
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"set": keys})
},
}

type sourceJSON struct {
ID string `json:"id"`
Label string `json:"label"`
}

type sourcesJSON struct {
LoggedIn bool `json:"logged_in"`
HasBackend bool `json:"has_backend"`
Endpoint string `json:"endpoint"` // custom endpoint (MEMCODE_ENDPOINT_URL), if any
CredentialSource string `json:"credential_source"` // selected subscription source, if any
Subscriptions []sourceJSON `json:"subscriptions"` // detected, usable at no extra cost
}

var guiConfigSourcesCmd = &cobra.Command{
Use: "sources",
Short: "Report configured backend + detected subscriptions as JSON",
Long: "Print what a first-run wizard needs: login state, whether any backend is configured, a custom endpoint or selected subscription source if present, and the subscriptions detected on this machine (Claude/ChatGPT/Copilot) that would cost nothing extra.",
RunE: func(_ *cobra.Command, _ []string) error {
provider.LoadDotEnv()
src := provider.APITokenSource()
loggedIn := src != "" && src != "none"
credSource := strings.TrimSpace(os.Getenv(provider.EnvCredentialSource))
endpoint := strings.TrimSpace(os.Getenv(provider.EnvEndpointURL))

var subs []sourceJSON
if claudesub.Available() {
subs = append(subs, sourceJSON{ID: "claude", Label: "Claude (Pro/Max) subscription"})
}
if codex.Available() {
subs = append(subs, sourceJSON{ID: "codex", Label: "ChatGPT (Codex) subscription"})
}
if copilot.Available() {
subs = append(subs, sourceJSON{ID: "copilot", Label: "GitHub Copilot subscription"})
}

ownKey := false
for _, env := range provider.OwnKeyEnvs() {
if os.Getenv(env) != "" {
ownKey = true
break
}
}
hasBackend := loggedIn || credSource != "" || endpoint != "" || ownKey

return json.NewEncoder(os.Stdout).Encode(sourcesJSON{
LoggedIn: loggedIn,
HasBackend: hasBackend,
Endpoint: endpoint,
CredentialSource: credSource,
Subscriptions: subs,
})
},
}

func init() {
guiConfigSetCmd.Flags().Bool("stdin", false, "read KEY=VALUE lines from stdin (use for secrets)")
guiConfigSetCmd.Flags().Bool("json", true, "output JSON (default; only format)")
guiConfigSourcesCmd.Flags().Bool("json", true, "output JSON (default; only format)")
guiConfigCmd.AddCommand(guiConfigSetCmd)
guiConfigCmd.AddCommand(guiConfigSourcesCmd)
}
Loading