diff --git a/.github/workflows/build-matrix.yml b/.github/workflows/build-matrix.yml new file mode 100644 index 00000000000..51baa863a7e --- /dev/null +++ b/.github/workflows/build-matrix.yml @@ -0,0 +1,100 @@ +name: build-matrix + +on: + push: + branches: ["main", "dev"] + pull_request: + paths: + - "cmd/super-ollama/**" + - "internal/**" + - "go.mod" + - "go.sum" + - ".github/workflows/build-matrix.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CGO_ENABLED: "0" + +jobs: + build: + name: build / ${{ matrix.os }} / ${{ matrix.arch }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + goos: linux + arch: amd64 + goarch: amd64 + - os: ubuntu-latest + goos: linux + arch: arm64 + goarch: arm64 + - os: macos-latest + goos: darwin + arch: amd64 + goarch: amd64 + - os: macos-latest + goos: darwin + arch: arm64 + goarch: arm64 + - os: windows-latest + goos: windows + arch: amd64 + goarch: amd64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Build super-ollama binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + go build -o bin/super-ollama${{ matrix.goos == 'windows' && '.exe' || '' }} \ + -ldflags="-s -w" \ + ./cmd/super-ollama + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: super-ollama-${{ matrix.goos }}-${{ matrix.goarch }} + path: bin/super-ollama${{ matrix.goos == 'windows' && '.exe' || '' }} + if-no-files-found: error + + # Unit tests for internal/config and the prompt pipeline + test-prompt-pipeline: + name: test / prompt pipeline / ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Run internal/config tests + run: go test -v -count=1 ./internal/config/... + + - name: Run cmd/config tests + run: go test -v -count=1 ./cmd/config/... diff --git a/cmd/super-ollama/main.go b/cmd/super-ollama/main.go index 3dc36df4b7b..7d521992355 100644 --- a/cmd/super-ollama/main.go +++ b/cmd/super-ollama/main.go @@ -24,6 +24,7 @@ import ( ) var modelFlag string +var noProfileFlag bool // superOllamaLogLevel defaults to WARN so inference scheduler noise stays off stderr. // Set OLLAMA_DEBUG (same semantics as ollama) to enable INFO/DEBUG/TRACE. @@ -43,6 +44,7 @@ func main() { Short: "Terminal-native local LLM CLI (super-ollama fork)", } root.PersistentFlags().StringVar(&modelFlag, "model", "", "model name (overrides config default_model)") + root.PersistentFlags().BoolVar(&noProfileFlag, "no-profile", false, "skip injecting ~/.super-ollama/profile.md into the system prompt") root.AddCommand( newHiddenRunnerCmd(), @@ -52,7 +54,7 @@ func main() { newStubCmd("todo", "TODO manager (coming in a later phase)"), newStubCmd("snap", "Screenshot capture (coming in a later phase)"), newStubCmd("learn", "Learning-loop re-index (coming in a later phase)"), - newConfigShowCmd(), + newConfigCmd(), ) cobra.CheckErr(root.ExecuteContext(context.Background())) @@ -86,6 +88,35 @@ func resolveModel() (string, error) { return cfg.DefaultModel, nil } +// buildSystemPrompt assembles the system prompt from profile.md and an optional +// named command prompt (e.g. "ask", "email"). If noProfileFlag is set, profile.md +// is skipped. promptName may be "" to load only the profile. +func buildSystemPrompt(promptName string) (string, error) { + var parts []string + + if !noProfileFlag { + profile, err := config.LoadProfile() + if err != nil { + return "", fmt.Errorf("loading profile: %w", err) + } + if profile != "" { + parts = append(parts, profile) + } + } + + if promptName != "" { + named, err := config.LoadPrompt(promptName) + if err != nil { + return "", fmt.Errorf("loading prompt %q: %w", promptName, err) + } + if named != "" { + parts = append(parts, named) + } + } + + return strings.Join(parts, "\n\n"), nil +} + func readPrompt(args []string) (string, error) { if len(args) > 0 { return strings.Join(args, " "), nil @@ -114,6 +145,11 @@ func newAskCmd() *cobra.Command { return fmt.Errorf("prompt is empty; pass arguments or stdin") } + system, err := buildSystemPrompt("ask") + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -123,7 +159,7 @@ func newAskCmd() *cobra.Command { } defer eng.Close() - out, err := eng.Generate(ctx, model, prompt, nil) + out, err := eng.Generate(ctx, model, prompt, system, nil) if err != nil { return err } @@ -143,6 +179,11 @@ func newChatCmd() *cobra.Command { return err } + system, err := buildSystemPrompt("") + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -153,6 +194,9 @@ func newChatCmd() *cobra.Command { defer eng.Close() var msgs []api.Message + if system != "" { + msgs = append(msgs, api.Message{Role: "system", Content: system}) + } scanner := bufio.NewScanner(os.Stdin) ui.Printf("Model: %s — type /bye to exit\n", model) for { @@ -205,9 +249,36 @@ func newStubCmd(name, short string) *cobra.Command { } } +func newConfigCmd() *cobra.Command { + configCmd := &cobra.Command{ + Use: "config", + Short: "Manage super-ollama configuration", + } + configCmd.AddCommand(newConfigShowCmd(), newConfigInitCmd()) + return configCmd +} + +func newConfigInitCmd() *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Create ~/.super-ollama/ with default config files", + RunE: func(*cobra.Command, []string) error { + if err := config.Init(); err != nil { + return err + } + dir, err := config.Dir() + if err != nil { + return err + } + ui.Printf("Initialized super-ollama directory: %s\n", dir) + return nil + }, + } +} + func newConfigShowCmd() *cobra.Command { return &cobra.Command{ - Use: "config", + Use: "show", Short: "Show effective config path and default model", RunE: func(*cobra.Command, []string) error { path, err := config.ResolvedPath() @@ -218,11 +289,27 @@ func newConfigShowCmd() *cobra.Command { if err != nil { return err } - ui.Printf("config file: %s\n", path) - ui.Printf("default_model: %s\n", cfg.DefaultModel) + ui.Printf("config file: %s\n", path) + ui.Printf("default_model: %s\n", cfg.DefaultModel) + ui.Printf("embedding_model: %s\n", cfg.EmbeddingModel) if strings.TrimSpace(modelFlag) != "" { ui.Printf("active --model override: %s\n", strings.TrimSpace(modelFlag)) } + dir, err := config.Dir() + if err != nil { + return err + } + ui.Printf("super-ollama dir: %s\n", dir) + + profile, err := config.LoadProfile() + if err != nil { + return err + } + if profile != "" { + ui.Printf("profile.md: loaded (%d bytes)\n", len(profile)) + } else { + ui.Printf("profile.md: not found (run 'super-ollama config init' to create)\n") + } return nil }, } diff --git a/install.sh b/install.sh new file mode 100755 index 00000000000..987410122d1 --- /dev/null +++ b/install.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# install.sh — cross-platform installer for super-ollama +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/Kritarth-Dandapat/super-ollama/main/install.sh | bash +# +# Options (via env vars): +# INSTALL_DIR directory to install the binary (default: /usr/local/bin) +# VERSION release tag to download (default: latest) +# NO_INIT set to 1 to skip ~/.super-ollama/ initialization + +set -euo pipefail + +REPO="Kritarth-Dandapat/super-ollama" +BINARY="super-ollama" +INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" +VERSION="${VERSION:-}" +NO_INIT="${NO_INIT:-0}" + +# ── helpers ────────────────────────────────────────────────────────────────── + +log() { printf '\033[1;32m==> \033[0m%s\n' "$*" >&2; } +warn() { printf '\033[1;33mWARN \033[0m%s\n' "$*" >&2; } +die() { printf '\033[1;31mERROR \033[0m%s\n' "$*" >&2; exit 1; } + +need() { + command -v "$1" >/dev/null 2>&1 || die "Required command '$1' not found. Please install it and retry." +} + +# ── detect OS and architecture ──────────────────────────────────────────────── + +detect_platform() { + local os arch + + case "$(uname -s)" in + Linux*) os="linux" ;; + Darwin*) os="darwin" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + os="windows" ;; + *) die "Unsupported operating system: $(uname -s)" ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + armv7l) arch="arm" ;; + *) die "Unsupported architecture: $(uname -m)" ;; + esac + + echo "${os}-${arch}" +} + +# ── resolve latest release version ─────────────────────────────────────────── + +resolve_version() { + if [[ -n "$VERSION" ]]; then + echo "$VERSION" + return + fi + need curl + local latest + latest=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | head -1 \ + | sed 's/.*"tag_name": *"\(.*\)".*/\1/') + [[ -n "$latest" ]] || die "Could not determine latest release. Set VERSION= to specify a tag." + echo "$latest" +} + +# ── download and install ────────────────────────────────────────────────────── + +download_and_install() { + local platform version asset_name url tmpdir + + platform=$(detect_platform) + version=$(resolve_version) + log "Installing ${BINARY} ${version} for ${platform}" + + # Asset naming convention: super-ollama---[.exe] + local os="${platform%%-*}" + if [[ "$os" == "windows" ]]; then + asset_name="${BINARY}-${version}-${platform}.exe" + else + asset_name="${BINARY}-${version}-${platform}" + fi + + url="https://github.com/${REPO}/releases/download/${version}/${asset_name}" + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + + log "Downloading ${url}" + need curl + curl -fsSL -o "${tmpdir}/${BINARY}" "$url" \ + || die "Download failed. Check that ${version} has a release asset for ${platform}." + + chmod +x "${tmpdir}/${BINARY}" + + # Install the binary + if [[ -w "$INSTALL_DIR" ]]; then + mv "${tmpdir}/${BINARY}" "${INSTALL_DIR}/${BINARY}" + else + log "Requesting sudo to write to ${INSTALL_DIR}" + sudo mv "${tmpdir}/${BINARY}" "${INSTALL_DIR}/${BINARY}" + fi + + log "Installed ${BINARY} to ${INSTALL_DIR}/${BINARY}" +} + +# ── initialise ~/.super-ollama/ ─────────────────────────────────────────────── + +init_config_dir() { + if [[ "$NO_INIT" == "1" ]]; then + return + fi + + log "Initialising config directory …" + local dir + if [[ -n "${XDG_CONFIG_HOME:-}" ]]; then + dir="${XDG_CONFIG_HOME}/super-ollama" + else + dir="${HOME}/.super-ollama" + fi + + mkdir -p "${dir}/prompts" + + write_if_absent() { + local path="$1" content="$2" + if [[ ! -f "$path" ]]; then + printf '%s' "$content" > "$path" + log " Created $path" + else + warn " Skipping $path (already exists)" + fi + } + + write_if_absent "${dir}/config.toml" '# super-ollama runtime configuration +default_model = "gemma3:1b" +embedding_model = "nomic-embed-text" +db_path = "~/.super-ollama/data.db" +todo_path = "~/TODO.md" +capture_interval_minutes = 5 +capture_enabled = false +' + + write_if_absent "${dir}/profile.md" '# About me + +Name: +Role: +Writing style: clear, direct, minimal jargon +Time zone: UTC + +# Context I want the AI to always know + +- Add bullet points here with context you want injected into every prompt. +' + + for name in ask email todo writing; do + case "$name" in + ask) body="Answer concisely and accurately." ;; + email) body="You are a professional email assistant. Write polished, clear emails." ;; + todo) body="You help manage tasks and priorities. Be concise and actionable." ;; + writing) body="You are a writing assistant. Improve clarity, style, and structure." ;; + esac + write_if_absent "${dir}/prompts/${name}.md" "# System prompt for ${name} + +${body} +" + done +} + +# ── verify installation ─────────────────────────────────────────────────────── + +verify() { + if command -v "${BINARY}" >/dev/null 2>&1; then + log "${BINARY} is available at $(command -v "${BINARY}")" + else + warn "${BINARY} is not in PATH. Add ${INSTALL_DIR} to your PATH." + fi +} + +# ── main ────────────────────────────────────────────────────────────────────── + +main() { + download_and_install + init_config_dir + verify + + cat >&2 <<'EOF' + +┌──────────────────────────────────────────────────────┐ +│ super-ollama installed! Quick-start: │ +│ │ +│ super-ollama config show # check config │ +│ super-ollama ask "hello world" # one-shot prompt │ +│ super-ollama chat # interactive REPL│ +│ │ +│ Edit ~/.super-ollama/profile.md to personalise │ +│ every inference call. │ +└──────────────────────────────────────────────────────┘ +EOF +} + +main "$@" diff --git a/internal/config/config.go b/internal/config/config.go index 1e6dbe87c30..855390ed0ed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" "strings" @@ -8,35 +9,52 @@ import ( "github.com/pelletier/go-toml/v2" ) -const defaultModel = "gemma3:1b" +const ( + defaultModel = "gemma3:1b" + defaultEmbeddingModel = "nomic-embed-text" +) // Config holds super-ollama runtime settings from config.toml. type Config struct { - DefaultModel string `toml:"default_model"` + DefaultModel string `toml:"default_model"` + EmbeddingModel string `toml:"embedding_model"` + DBPath string `toml:"db_path"` + TodoPath string `toml:"todo_path"` + CaptureIntervalMinutes int `toml:"capture_interval_minutes"` + CaptureEnabled bool `toml:"capture_enabled"` } -// ResolvedPath returns the path to the active config file. -func ResolvedPath() (string, error) { +// Dir returns the ~/.super-ollama directory, honouring XDG_CONFIG_HOME. +func Dir() (string, error) { if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { - return filepath.Join(xdg, "super-ollama", "config.toml"), nil + return filepath.Join(xdg, "super-ollama"), nil } home, err := os.UserHomeDir() if err != nil { return "", err } - return filepath.Join(home, ".super-ollama", "config.toml"), nil + return filepath.Join(home, ".super-ollama"), nil +} + +// ResolvedPath returns the path to the active config file. +func ResolvedPath() (string, error) { + dir, err := Dir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.toml"), nil } // Load reads config.toml. Missing file yields defaults; other errors are returned. func Load() (Config, error) { path, err := ResolvedPath() if err != nil { - return Config{DefaultModel: defaultModel}, err + return Config{DefaultModel: defaultModel, EmbeddingModel: defaultEmbeddingModel}, err } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return Config{DefaultModel: defaultModel}, nil + return Config{DefaultModel: defaultModel, EmbeddingModel: defaultEmbeddingModel}, nil } return Config{}, err } @@ -47,5 +65,110 @@ func Load() (Config, error) { if strings.TrimSpace(c.DefaultModel) == "" { c.DefaultModel = defaultModel } + if strings.TrimSpace(c.EmbeddingModel) == "" { + c.EmbeddingModel = defaultEmbeddingModel + } return c, nil } + +// LoadProfile reads ~/.super-ollama/profile.md and returns its content. +// Returns ("", nil) when the file does not exist. +func LoadProfile() (string, error) { + dir, err := Dir() + if err != nil { + return "", err + } + data, err := os.ReadFile(filepath.Join(dir, "profile.md")) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +// LoadPrompt reads ~/.super-ollama/prompts/.md and returns its content. +// Returns ("", nil) when the file does not exist. +func LoadPrompt(name string) (string, error) { + if strings.TrimSpace(name) == "" { + return "", fmt.Errorf("prompt name cannot be empty") + } + dir, err := Dir() + if err != nil { + return "", err + } + data, err := os.ReadFile(filepath.Join(dir, "prompts", name+".md")) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +// defaultConfigTOML is the skeleton config.toml written on first init. +const defaultConfigTOML = `# super-ollama runtime configuration +default_model = "gemma3:1b" +embedding_model = "nomic-embed-text" +db_path = "~/.super-ollama/data.db" +todo_path = "~/TODO.md" +capture_interval_minutes = 5 +capture_enabled = false +` + +// defaultProfileMD is the skeleton profile.md written on first init. +const defaultProfileMD = `# About me + +Name: +Role: +Writing style: clear, direct, minimal jargon +Time zone: UTC + +# Context I want the AI to always know + +- Add bullet points here with context you want injected into every prompt. +` + +// Init creates ~/.super-ollama/ with default skeleton files if they do not exist. +// It is safe to call on every startup; existing files are never overwritten. +func Init() error { + dir, err := Dir() + if err != nil { + return err + } + promptsDir := filepath.Join(dir, "prompts") + if err := os.MkdirAll(promptsDir, 0o755); err != nil { + return err + } + + writeIfAbsent := func(path, content string) error { + if _, err := os.Stat(path); err == nil { + return nil // already exists + } + return os.WriteFile(path, []byte(content), 0o644) + } + + if err := writeIfAbsent(filepath.Join(dir, "config.toml"), defaultConfigTOML); err != nil { + return err + } + if err := writeIfAbsent(filepath.Join(dir, "profile.md"), defaultProfileMD); err != nil { + return err + } + + // Skeleton prompt files for built-in commands. + prompts := map[string]string{ + "ask": "# System prompt for the ask command\n\nAnswer concisely and accurately.\n", + "email": "# System prompt for email operations\n\nYou are a professional email assistant. Write polished, clear emails.\n", + "todo": "# System prompt for TODO operations\n\nYou help manage tasks and priorities. Be concise and actionable.\n", + "writing": "# System prompt for writing assistance\n\nYou are a writing assistant. Improve clarity, style, and structure.\n", + } + for name, content := range prompts { + path := filepath.Join(promptsDir, name+".md") + if err := writeIfAbsent(path, content); err != nil { + return err + } + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000000..9efa0c6a288 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,265 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// setTestHome redirects UserHomeDir and XDG_CONFIG_HOME so that all operations +// target tmpDir instead of the real home directory. +func setTestHome(t *testing.T, tmpDir string) { + t.Helper() + t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) // Windows + t.Setenv("XDG_CONFIG_HOME", "") +} + +func TestDir(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + got, err := Dir() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(tmpDir, ".super-ollama") + if got != want { + t.Errorf("Dir() = %q, want %q", got, want) + } +} + +func TestDirXDG(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + xdgDir := filepath.Join(tmpDir, "xdg") + t.Setenv("XDG_CONFIG_HOME", xdgDir) + + got, err := Dir() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(xdgDir, "super-ollama") + if got != want { + t.Errorf("Dir() with XDG = %q, want %q", got, want) + } +} + +func TestResolvedPath(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + got, err := ResolvedPath() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(tmpDir, ".super-ollama", "config.toml") + if got != want { + t.Errorf("ResolvedPath() = %q, want %q", got, want) + } +} + +func TestLoad_Defaults(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.DefaultModel != defaultModel { + t.Errorf("DefaultModel = %q, want %q", cfg.DefaultModel, defaultModel) + } + if cfg.EmbeddingModel != defaultEmbeddingModel { + t.Errorf("EmbeddingModel = %q, want %q", cfg.EmbeddingModel, defaultEmbeddingModel) + } +} + +func TestLoad_FromFile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + dir := filepath.Join(tmpDir, ".super-ollama") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + toml := `default_model = "llama3:8b" +embedding_model = "nomic-embed-text" +capture_enabled = true +capture_interval_minutes = 10 +` + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(toml), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.DefaultModel != "llama3:8b" { + t.Errorf("DefaultModel = %q, want llama3:8b", cfg.DefaultModel) + } + if !cfg.CaptureEnabled { + t.Error("expected CaptureEnabled = true") + } + if cfg.CaptureIntervalMinutes != 10 { + t.Errorf("CaptureIntervalMinutes = %d, want 10", cfg.CaptureIntervalMinutes) + } +} + +func TestLoadProfile_Missing(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + got, err := LoadProfile() + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("expected empty profile when file missing, got %q", got) + } +} + +func TestLoadProfile_Present(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + dir := filepath.Join(tmpDir, ".super-ollama") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + content := "# About me\n\nName: Alice\nRole: Engineer\n" + if err := os.WriteFile(filepath.Join(dir, "profile.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + got, err := LoadProfile() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "Name: Alice") { + t.Errorf("profile missing expected content, got %q", got) + } +} + +func TestLoadPrompt_Missing(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + got, err := LoadPrompt("ask") + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("expected empty prompt when file missing, got %q", got) + } +} + +func TestLoadPrompt_EmptyName(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + _, err := LoadPrompt("") + if err == nil { + t.Fatal("expected error for empty prompt name") + } +} + +func TestLoadPrompt_Present(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + promptsDir := filepath.Join(tmpDir, ".super-ollama", "prompts") + if err := os.MkdirAll(promptsDir, 0o755); err != nil { + t.Fatal(err) + } + content := "You are a helpful assistant." + if err := os.WriteFile(filepath.Join(promptsDir, "ask.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + got, err := LoadPrompt("ask") + if err != nil { + t.Fatal(err) + } + if got != content { + t.Errorf("LoadPrompt() = %q, want %q", got, content) + } +} + +func TestInit_CreatesLayout(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + if err := Init(); err != nil { + t.Fatal(err) + } + + dir := filepath.Join(tmpDir, ".super-ollama") + + // config.toml should exist + if _, err := os.Stat(filepath.Join(dir, "config.toml")); os.IsNotExist(err) { + t.Error("config.toml was not created") + } + + // profile.md should exist + if _, err := os.Stat(filepath.Join(dir, "profile.md")); os.IsNotExist(err) { + t.Error("profile.md was not created") + } + + // prompts/ directory with skeleton files + for _, name := range []string{"ask", "email", "todo", "writing"} { + path := filepath.Join(dir, "prompts", name+".md") + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("prompts/%s.md was not created", name) + } + } +} + +func TestInit_Idempotent(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + if err := Init(); err != nil { + t.Fatal("first Init failed:", err) + } + + // Write custom content to profile.md + dir := filepath.Join(tmpDir, ".super-ollama") + customContent := "# Custom profile\n" + if err := os.WriteFile(filepath.Join(dir, "profile.md"), []byte(customContent), 0o644); err != nil { + t.Fatal(err) + } + + if err := Init(); err != nil { + t.Fatal("second Init failed:", err) + } + + // Custom content must not be overwritten + data, err := os.ReadFile(filepath.Join(dir, "profile.md")) + if err != nil { + t.Fatal(err) + } + if string(data) != customContent { + t.Errorf("Init() overwrote existing profile.md; got %q", string(data)) + } +} + +func TestInit_ConfigTomlIsValidTOML(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + if err := Init(); err != nil { + t.Fatal(err) + } + + // Load() must succeed on the generated config.toml + cfg, err := Load() + if err != nil { + t.Fatalf("Load() after Init() failed: %v", err) + } + if cfg.DefaultModel == "" { + t.Error("DefaultModel must be non-empty after Init()") + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 3bf55adadec..68fad9fba0a 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -44,11 +44,13 @@ func (e *Engine) Close() { } // Generate runs a one-shot completion (non-streaming). -func (e *Engine) Generate(ctx context.Context, modelName, prompt string, opts Options) (string, error) { +// system is prepended as the model's system prompt; pass "" to use the model default. +func (e *Engine) Generate(ctx context.Context, modelName, prompt, system string, opts Options) (string, error) { stream := false req := api.GenerateRequest{ Model: modelName, Prompt: prompt, + System: system, Options: opts, Stream: &stream, } @@ -72,12 +74,14 @@ func (e *Engine) Generate(ctx context.Context, modelName, prompt string, opts Op } // StreamGenerate streams token deltas on out; the channel is closed when the request finishes. -func (e *Engine) StreamGenerate(ctx context.Context, modelName, prompt string, opts Options, out chan<- string) error { +// system is prepended as the model's system prompt; pass "" to use the model default. +func (e *Engine) StreamGenerate(ctx context.Context, modelName, prompt, system string, opts Options, out chan<- string) error { defer close(out) stream := true req := api.GenerateRequest{ Model: modelName, Prompt: prompt, + System: system, Options: opts, Stream: &stream, }