Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
*.so
*.dylib

# Compiled CLI binary at repo root (use make build → bin/)
/cli

# Test binary, built with `go test -c`
*.test

Expand Down Expand Up @@ -46,6 +49,7 @@ Thumbs.db
.openclaude-profile.json
.blackboxcli/
claude-code/
private/

# Temporary files
*.log
Expand Down
Binary file added api
Binary file not shown.
Binary file added cli
Binary file not shown.
105 changes: 105 additions & 0 deletions cmd/cli/appdir/appdir.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Package appdir is the single source of truth for the CLI's filesystem layout.
//
// All persistent data lives under Root() (~/.config/nexus-cli/ by default, or
// the value of NEXUS_RUNTIME_ROOT). Session-scoped data is isolated under
// sessions/{session_id}/ so deleting a session is a single os.RemoveAll call.
//
// Directory layout:
//
// ~/.config/nexus-cli/
// ├── logs/
// ├── documents/ ← user-uploaded PDFs and docs (global, persistent)
// ├── rag/ ← RAG-indexed documents (global, persistent)
// └── sessions/
// └── {session_id}/
// ├── screenshots/ ← browser screenshots
// ├── plans/ ← plan-mode markdown files
// ├── tools/ ← browser downloads
// └── artifacts/
// ├── web/ ← web-scraped content
// ├── images/ ← AI-generated images
// └── audio/ ← TTS / STT audio
package appdir

import (
"os"
"path/filepath"

"github.com/EngineerProjects/nexus-engine/pkg/runtimepath"
)

// Root returns the application root directory, resolved via NEXUS_RUNTIME_ROOT
// or the platform default (~/.config/nexus-cli/ on Linux/macOS).
func Root() string { return runtimepath.ResolveRoot("") }

// ─── Global directories ───────────────────────────────────────────────────────

func LogsDir() string { return runtimepath.LogsDir("") }
func SessionsDir() string { return runtimepath.SessionsDir("") }

// ─── Per-session directories ──────────────────────────────────────────────────

func SessionDir(sessionID string) string { return runtimepath.SessionDir("", sessionID) }
func SessionScreenshotsDir(sessionID string) string {
return runtimepath.SessionScreenshotsDir("", sessionID)
}
func SessionPlansDir(sessionID string) string { return runtimepath.SessionPlansDir("", sessionID) }
func SessionToolsDir(sessionID string) string { return runtimepath.SessionToolsDir("", sessionID) }
func SessionLogPath(sessionID string) string { return runtimepath.SessionLogPath("", sessionID) }

// Artifact subdirectories — agent-produced content, session-scoped.
func SessionArtifactsDir(sessionID string) string {
return runtimepath.SessionArtifactsDir("", sessionID)
}
func SessionArtifactsWebDir(sessionID string) string {
return runtimepath.SessionArtifactsWebDir("", sessionID)
}
func SessionArtifactsImagesDir(sessionID string) string {
return runtimepath.SessionArtifactsImagesDir("", sessionID)
}
func SessionArtifactsAudioDir(sessionID string) string {
return runtimepath.SessionArtifactsAudioDir("", sessionID)
}

// ─── Lifecycle helpers ────────────────────────────────────────────────────────

// EnsureAppDirs creates the top-level directories required at startup.
// Safe to call multiple times (uses os.MkdirAll).
func EnsureAppDirs() error {
dirs := []string{
LogsDir(),
SessionsDir(),
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
return nil
}

// EnsureSessionDir creates sessions/{id}/ and all standard subdirectories.
// Call this when a session starts, before any tools run. Safe to call multiple times.
func EnsureSessionDir(sessionID string) error {
dirs := []string{
SessionScreenshotsDir(sessionID),
SessionPlansDir(sessionID),
SessionToolsDir(sessionID),
SessionArtifactsWebDir(sessionID),
SessionArtifactsImagesDir(sessionID),
SessionArtifactsAudioDir(sessionID),
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
return nil
}

// DeleteSessionDir removes sessions/{id}/ and all its contents in one call.
// Covers screenshots, plans, tools, artifacts, logs — everything.
// Errors are intentionally ignored; DB cleanup is the authoritative deletion.
func DeleteSessionDir(sessionID string) {
_ = os.RemoveAll(filepath.Join(SessionsDir(), sessionID))
}
69 changes: 58 additions & 11 deletions cmd/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"

Expand All @@ -16,14 +17,16 @@ import (

// credentialKey constants — these are the keys used in the credentials table.
const (
credKeyAPIKey = "api_key"
credKeyBaseURL = "provider_base_url"
credKeyRegion = "provider_region"
credKeyProjectID = "provider_project_id"
credKeyResource = "provider_resource"
credKeyTavily = "TAVILY_API_KEY"
credKeyExa = "EXA_API_KEY"
credKeyJina = "JINA_API_KEY"
credKeyModel = "model"
credKeyAPIKey = "api_key"
credKeyBaseURL = "provider_base_url"
credKeyRegion = "provider_region"
credKeyProjectID = "provider_project_id"
credKeyResource = "provider_resource"
credKeyTavily = "TAVILY_API_KEY"
credKeyExa = "EXA_API_KEY"
credKeyJina = "JINA_API_KEY"
credKeyLangSearch = "LANGSEARCH_API_KEY"
)

func runConfig(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
Expand Down Expand Up @@ -153,6 +156,11 @@ func runConfig(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
return err
}

// Persist model selection to DB so it survives YAML resets.
if err := saveCredential(database, credKeyModel, config.Model); err != nil {
return err
}

// Strip runtime-only secrets from YAML before saving.
yamlConfig := stripRuntimeSecrets(config)
if err := engineconfig.Save(yamlConfig); err != nil {
Expand All @@ -166,12 +174,15 @@ func runConfig(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
return nil
}

// configureSearchKeys prompts for Tavily / Exa / Jina API keys.
// configureSearchKeys prompts for Tavily / Exa / Jina / LangSearch API keys.
func configureSearchKeys(reader *bufio.Reader, stdout io.Writer, database *db.DB, config *engineconfig.Config) error {
fmt.Fprintln(stdout, "\n─── Search tool API keys ────────────────────────────────────────")
fmt.Fprintln(stdout, "Leave blank to keep the current value. Enter \"-\" to clear.")
fmt.Fprintln(stdout)

// LangSearch has no Config struct field — use a local var and apply as env var.
langSearchCurrent := os.Getenv("LANGSEARCH_API_KEY")

fields := []struct {
credKey string
label string
Expand All @@ -181,6 +192,7 @@ func configureSearchKeys(reader *bufio.Reader, stdout io.Writer, database *db.DB
{credKeyTavily, "Tavily API key", "TAVILY_API_KEY", &config.TavilyAPIKey},
{credKeyExa, "Exa API key", "EXA_API_KEY", &config.ExaAPIKey},
{credKeyJina, "Jina AI API key", "JINA_API_KEY", &config.JinaAPIKey},
{credKeyLangSearch, "LangSearch API key", "LANGSEARCH_API_KEY", &langSearchCurrent},
}

for _, f := range fields {
Expand Down Expand Up @@ -244,6 +256,7 @@ func printConfigSummary(out io.Writer, config engineconfig.Config, model sdk.Mod
{"tavily", config.TavilyAPIKey},
{"exa", config.ExaAPIKey},
{"jina", config.JinaAPIKey},
{"langsearch", os.Getenv("LANGSEARCH_API_KEY")},
}
anySearch := false
for _, f := range searchFields {
Expand Down Expand Up @@ -411,18 +424,35 @@ func loadCredsIntoConfig(database *db.DB, config *engineconfig.Config) {
// TUI config panel), then falls back to the global key (written by `nexus config`).
loadCredScoped := func(fieldKey, providerID string) string {
if providerID != "" {
if v := loadCred(fieldKey + ":" + strings.ToLower(providerID)); v != "" {
// Normalize providerID to ensure consistent lookups (e.g. "zai" -> "z-ai").
normalized := string(engineconfig.ResolveProvider(providerID))
if normalized == "" {
normalized = strings.ToLower(providerID)
}
if v := loadCred(fieldKey + ":" + normalized); v != "" {
return v
}
}
return loadCred(fieldKey)
}

// Load persisted model selection from DB when YAML has none.
// This is the primary source of truth for which provider is "active".
if strings.TrimSpace(config.Model) == "" {
if v := loadCred(credKeyModel); v != "" {
config.Model = v
}
}

// Determine the active provider from the config model string.
// We use the raw model string to avoid circularity in resolveModel.
activeProvider := ""
if m := resolveModel(*config); m.Provider != "" {
if m := engineconfig.ParseModelIdentifier(config.Model); m.Provider != "" {
activeProvider = string(m.Provider)
}
if activeProvider == "" {
activeProvider = string(engineconfig.DetectProviderFromModel(config.Model))
}

if v := loadCredScoped(credKeyAPIKey, activeProvider); v != "" {
config.APIKey = v
Expand All @@ -442,9 +472,22 @@ func loadCredsIntoConfig(database *db.DB, config *engineconfig.Config) {
config.TavilyAPIKey = loadCred(credKeyTavily)
config.ExaAPIKey = loadCred(credKeyExa)
config.JinaAPIKey = loadCred(credKeyJina)

if config.WebSearchProvider == "" {
config.WebSearchProvider = loadCred("WEB_SEARCH_PROVIDER")
}

// LangSearch and SearXNG have no Config struct field — apply directly as env vars.
if v := loadCred(credKeyLangSearch); v != "" && os.Getenv("LANGSEARCH_API_KEY") == "" {
os.Setenv("LANGSEARCH_API_KEY", v)
}
if v := loadCred(credKeySearXNG); v != "" && os.Getenv("SEARXNG_BASE_URL") == "" {
os.Setenv("SEARXNG_BASE_URL", v)
}
}

func stripRuntimeSecrets(config engineconfig.Config) engineconfig.Config {
// Strip secrets — stored in the DB, never in YAML.
config.APIKey = ""
config.ProviderBaseURL = ""
config.ProviderRegion = ""
Expand All @@ -453,6 +496,10 @@ func stripRuntimeSecrets(config engineconfig.Config) engineconfig.Config {
config.TavilyAPIKey = ""
config.ExaAPIKey = ""
config.JinaAPIKey = ""
// RuntimeRoot is always re-computed at startup from NEXUS_RUNTIME_ROOT or
// the XDG default (~/.config/nexus-cli). Never persist it so the YAML stays
// portable and doesn't hard-code absolute paths.
config.RuntimeRoot = ""
return config
}

Expand Down
11 changes: 11 additions & 0 deletions cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,20 @@ import (
"fmt"
"os"
"os/signal"
"path/filepath"

"github.com/EngineerProjects/nexus-engine/pkg/runtimepath"
)

func main() {
// Pin the CLI runtime root to ~/.config/nexus-cli, isolated from the
// nexus-product backend (~/.config/nexus). NEXUS_RUNTIME_ROOT takes precedence.
if os.Getenv(runtimepath.EnvRuntimeRoot) == "" {
if home, err := os.UserHomeDir(); err == nil {
os.Setenv(runtimepath.EnvRuntimeRoot, filepath.Join(home, ".config", "nexus-cli"))
}
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

Expand Down
11 changes: 9 additions & 2 deletions cmd/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,11 @@ func loadChatSession(ctx context.Context, client *sdk.Client, sessionID string)
func printChatBanner(out io.Writer, options runtimeOptions, session *sdk.Session, showThinking bool) {
fmt.Fprintf(out, "Nexus dev chat\n")
fmt.Fprintf(out, "workspace: %s\n", options.WorkingDir)
fmt.Fprintf(out, "model: %s/%s\n", options.Model.Provider, options.Model.Model)
modelLabel := options.Model.String()
if options.Model.Provider == "" {
modelLabel = "(not configured — run `nexus config`)"
}
fmt.Fprintf(out, "model: %s\n", modelLabel)
fmt.Fprintf(out, "permission: %s\n", options.PermissionMode)
fmt.Fprintf(out, "session: %s\n", session.GetID().String())
if showThinking {
Expand Down Expand Up @@ -280,14 +284,17 @@ func splitCommand(raw string) (string, string) {
}

func validateProviderSetup(options runtimeOptions) error {
if options.Model.Provider == "" {
return nil // nothing configured yet; TUI will guide the user
}
config, err := engineconfig.Load()
if err != nil {
return err
}
config.Model = options.Model.String()
config.APIKey = options.APIKey
if validateErr := engineconfig.ValidateProviderSetup(config, options.Model.Provider); validateErr != nil {
return fmt.Errorf("%w; run `go run ./cmd/cli config` first", validateErr)
return fmt.Errorf("%w; run `nexus config` first", validateErr)
}
return nil
}
Expand Down
Loading
Loading