diff --git a/cmd/foo/commands/embed.go b/cmd/foo/commands/embed.go index ab78bfa..ac687a3 100644 --- a/cmd/foo/commands/embed.go +++ b/cmd/foo/commands/embed.go @@ -14,8 +14,14 @@ import ( "hop.top/foo/internal/embed" kitcli "hop.top/kit/go/console/cli" "hop.top/kit/go/core/xdg" + "hop.top/kit/go/storage/sqlstore" ) +// embedSchemaVersion is the embeddings-store schema revision recorded in +// pre-migrate backup filenames. Bump when embed.NewStore's table layout +// changes so backups are labeled with the version they precede. +const embedSchemaVersion = 1 + func embedRootCmd() *cobra.Command { cmd := &cobra.Command{ Use: "embed", @@ -73,6 +79,7 @@ collection is "default"; override with --collection.`, return fmt.Errorf("store: %w", err) } + publishEvent(cmd.Context(), "foo.knowledge.embedding.created", map[string]any{"id": storedID, "collection": collection}) fmt.Fprintf(cmd.OutOrStdout(), "embedded %s into %q\n", storedID, collection) return nil }, @@ -140,6 +147,7 @@ same content produces new rows.`, } } + publishEvent(cmd.Context(), "foo.knowledge.embedding.created", map[string]any{"source": filePath, "collection": collection, "chunks": len(chunks)}) fmt.Fprintf(cmd.OutOrStdout(), "embedded %d chunks from %s into %q\n", len(chunks), filePath, collection) return nil }, @@ -254,6 +262,7 @@ delete collections; embeddings themselves are added with if err := store.DeleteCollection(args[0]); err != nil { return err } + publishEvent(cmd.Context(), "foo.knowledge.collection.deleted", map[string]any{"name": args[0]}) fmt.Fprintf(cmd.OutOrStdout(), "collection %q deleted\n", args[0]) return nil }, @@ -274,6 +283,16 @@ func openEmbedStore() (*embed.Store, error) { } dbPath := filepath.Join(stateDir, "embeddings.db") + + // Back up the live DB into /.dbs/ before NewStore runs its + // CREATE TABLE migrations. BackupBeforeMigrate is a no-op on first + // run (no file yet) and writes a timestamped copy otherwise, so a + // schema change never destroys recoverable data. Backups live in a + // hidden .dbs sibling, never beside the live DB. + if _, err := sqlstore.BackupBeforeMigrate(dbPath, embedSchemaVersion, sqlstore.WithBackupDir(filepath.Join(stateDir, ".dbs"))); err != nil { + return nil, fmt.Errorf("backup embeddings db: %w", err) + } + db, err := sql.Open("sqlite3", dbPath) if err != nil { return nil, fmt.Errorf("open db: %w", err) diff --git a/cmd/foo/commands/fragment.go b/cmd/foo/commands/fragment.go index d6b0cff..1a41d3b 100644 --- a/cmd/foo/commands/fragment.go +++ b/cmd/foo/commands/fragment.go @@ -102,7 +102,7 @@ func fragmentCreateCmd() *cobra.Command { } } - publishEvent(ctx, "foo.fragment.created", map[string]any{"alias": alias}) + publishEvent(ctx, "foo.knowledge.fragment.created", map[string]any{"alias": alias}) fmt.Fprintf(cmd.OutOrStdout(), "fragment %q saved\n", alias) return nil }, @@ -152,7 +152,7 @@ func fragmentDeleteCmd() *cobra.Command { return err } - publishEvent(ctx, "foo.fragment.deleted", map[string]any{"alias": args[0]}) + publishEvent(ctx, "foo.knowledge.fragment.deleted", map[string]any{"alias": args[0]}) fmt.Fprintf(cmd.OutOrStdout(), "fragment %q deleted\n", args[0]) return nil }, diff --git a/cmd/foo/commands/root.go b/cmd/foo/commands/root.go index c890f9d..76db9a3 100644 --- a/cmd/foo/commands/root.go +++ b/cmd/foo/commands/root.go @@ -9,11 +9,12 @@ import ( "log/slog" "os" "os/exec" + "path/filepath" "sort" "strings" - "charm.land/log/v2" tea "charm.land/bubbletea/v2" + "charm.land/log/v2" "github.com/spf13/cobra" "golang.org/x/term" "hop.top/foo/internal/config" @@ -29,9 +30,12 @@ import ( extdiscover "hop.top/kit/go/ai/ext/discover" extdispatch "hop.top/kit/go/ai/ext/dispatch" kitllm "hop.top/kit/go/ai/llm" + "hop.top/kit/go/console/alias" kitcli "hop.top/kit/go/console/cli" - "hop.top/kit/go/console/output" + kitcliconfig "hop.top/kit/go/console/cli/config" kitlog "hop.top/kit/go/console/log" + "hop.top/kit/go/console/output" + coreconfig "hop.top/kit/go/core/config" "hop.top/kit/go/core/upgrade" "hop.top/kit/go/core/xdg" kitbus "hop.top/kit/go/runtime/bus" @@ -60,12 +64,20 @@ var ( budgetTier string pickerDebug bool - cfg = config.Default() - root *kitcli.Root - logger *log.Logger + // Delegation-safety / scope globals (§8). Registered via + // Config.Globals so they live on the root's persistent flag set and + // every subcommand inherits them. + offline bool + profileName string + instanceName string + + cfg = config.Default() + root *kitcli.Root + logger *log.Logger eventBus kitbus.Bus - mgr *wsm.Manager - ws *wsm.Workspace + busNet *kitbus.NetworkAdapter + mgr *wsm.Manager + ws *wsm.Workspace ) var commandGroups = map[string]string{ @@ -77,6 +89,8 @@ var commandGroups = map[string]string{ "embed": "knowledge", "model": "organize", "provider": "organize", + "config": "management", + "alias": "management", "upgrade": "management", } @@ -98,13 +112,23 @@ func New(v string) *kitcli.Root { Short: "LLM workflows from the terminal", Accent: config.DefaultAccent, Help: kitcli.HelpConfig{ - Disclaimer: longDescription, + Disclaimer: longDescription, + ShowAliases: true, Groups: []kitcli.GroupConfig{ {ID: "knowledge", Title: "KNOWLEDGE"}, {ID: "organize", Title: "ORGANIZE"}, {ID: "interact", Title: "INTERACT"}, }, }, + // Scope/delegation globals (§8). --config -c is registered by kit + // automatically; these three complete the required set. Bound to + // package-level pointers so initializeRuntime can honor them + // without round-tripping through viper. + Globals: []kitcli.Flag{ + {Name: "offline", Usage: "Disable all network access (skips upgrade check and remote calls)", BoolVar: &offline}, + {Name: "profile", Usage: "Select the aps profile scoping config + secret lookups", StringVar: &profileName}, + {Name: "instance", Usage: "Select the backend instance (single-instance build: currently a no-op)", StringVar: &instanceName}, + }, }, kitcli.WithStatus(kitcli.StatusConfig{})) logger = kitlog.New(root.Viper) slog.SetDefault(slog.New(logger)) @@ -146,6 +170,8 @@ func New(v string) *kitcli.Root { root.Cmd.AddCommand(schemaCmd()) root.Cmd.AddCommand(modelCmd()) root.Cmd.AddCommand(providerCmd()) + root.Cmd.AddCommand(configCmd()) + root.Cmd.AddCommand(aliasCmd()) root.Cmd.AddCommand(upgradeCmd()) registerExtPlugins(root.Cmd) @@ -223,15 +249,38 @@ func initializeRuntime(cmd *cobra.Command, _ []string) error { } cfg = loaded + // --profile scopes the secret store: the aps profile name namespaces + // credential lookups so two profiles never collide on a bare key like + // "openai_api_key". Empty leaves the config-file Service intact. + // + // --instance selects a backend instance. foo is a single-instance + // local-state build (one embeddings.db, one schemas.db under the XDG + // state dir), so the flag is wired and plumbed but currently has no + // backend to switch — it is recorded for forward-compat and surfaced + // via config.Secrets.Service when both are set. + if profileName != "" { + cfg.Secrets.Service = profileName + if instanceName != "" { + cfg.Secrets.Service = profileName + "/" + instanceName + } + } else if instanceName != "" { + cfg.Secrets.Service = instanceName + } + verbose, _ := cmd.Root().PersistentFlags().GetCount("verbose") logger = kitlog.WithVerbose(root.Viper, verbose) slog.SetDefault(slog.New(logger)) - if cmd.Name() != "upgrade" { + // --offline suppresses every network call. The upgrade check is the + // one unconditional network touch in the runtime init path; gate it + // here. Downstream LLM/embedding calls read the same flag via + // networkAllowed(). + if !offline && cmd.Name() != "upgrade" { upgrade.NotifyIfAvailable(cmd.Context(), newUpgradeChecker(), cmd.ErrOrStderr()) } if eventBus == nil { eventBus = kitbus.New() + wireBusNetwork(cmd.Context()) } if cmd.CommandPath() == "foo" || cmd.CommandPath() == "foo repl" { @@ -473,6 +522,7 @@ applied to any prompt via --pattern.`, if err := pattern.Create(cfg.PatternsPath, args[0], systemPrompt); err != nil { return err } + publishEvent(cmd.Context(), "foo.knowledge.pattern.created", map[string]any{"name": args[0]}) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "pattern %q saved\n", args[0]) return nil }, @@ -493,6 +543,7 @@ applied to any prompt via --pattern.`, if err := pattern.Import(cfg.PatternsPath, args[0], name); err != nil { return err } + publishEvent(cmd.Context(), "foo.knowledge.pattern.imported", map[string]any{"path": args[0], "name": name}) _, _ = fmt.Fprintln(cmd.OutOrStdout(), "pattern imported") return nil }, @@ -510,6 +561,7 @@ applied to any prompt via --pattern.`, if err := pattern.Delete(cfg.PatternsPath, args[0]); err != nil { return err } + publishEvent(cmd.Context(), "foo.knowledge.pattern.deleted", map[string]any{"name": args[0]}) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "pattern %q deleted\n", args[0]) return nil }, @@ -578,6 +630,7 @@ supplied on the command line.`, if err := cfg.Save(); err != nil { return err } + publishEvent(cmd.Context(), "foo.organize.model.selected", map[string]any{"model": cfg.Model}) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "default model set to %q\n", cfg.Model) return nil }, @@ -648,6 +701,53 @@ credentials they expect are present in the configured secret store.`, return cmd } +// configCmd is the `config` parent. It owns no behavior of its own; the +// shared `path` / `paths` introspection subcommands are attached by +// kit's cli/config helper. The resolver adapts core/config's foo-scoped +// precedence chain to the cli/config.ResolvedPath wire type (identical +// fields, distinct package). +func configCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Inspect foo configuration", + Long: "Inspect the foo configuration precedence chain. `config path` prints the highest-precedence existing config file; `config paths` prints the full ordered chain.", + Args: cobra.NoArgs, + } + resolver := func(cwd string) []kitcliconfig.ResolvedPath { + raw := coreconfig.PathsForTool(cwd, "foo") + out := make([]kitcliconfig.ResolvedPath, len(raw)) + for i, r := range raw { + out[i] = kitcliconfig.ResolvedPath{ + Path: r.Path, + Source: r.Source, + Scope: r.Scope, + Exists: r.Exists, + } + } + return out + } + kitcliconfig.RegisterPathSubcommands(cmd, "foo", kitcliconfig.WithResolver(resolver)) + return cmd +} + +// aliasCmd wires kit's alias store and management command. Aliases are +// persisted as YAML under the foo config dir; kit's (*Root).AliasCmd +// supplies list/add/remove leaves and, with Config.Help.ShowAliases +// set, surfaces them in help output. A store-load failure is +// non-fatal: the command still mounts against an empty store so +// `alias add` keeps working. +func aliasCmd() *cobra.Command { + confDir, err := xdg.ConfigDir("foo") + if err != nil { + confDir = "" + } + store := alias.NewStore(filepath.Join(confDir, "aliases.yaml")) + if loadErr := store.Load(); loadErr != nil { + slog.Warn("alias.load.failed", slog.Any("err", loadErr)) + } + return root.AliasCmd(store) +} + func upgradeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "upgrade", @@ -655,6 +755,9 @@ func upgradeCmd() *cobra.Command { Long: `Check the GitHub releases for a newer version of foo and apply the upgrade in-place when one is available. Local binary mutation.`, RunE: func(cmd *cobra.Command, _ []string) error { + if !networkAllowed() { + return fmt.Errorf("`foo upgrade` requires network access; drop --offline and retry") + } return upgrade.RunCLI(cmd.Context(), newUpgradeChecker(), upgrade.CLIOptions{}) }, } @@ -849,6 +952,47 @@ func applyCommandGroups() { } } +// networkAllowed reports whether outbound network access is permitted. +// It is false when --offline is set, letting callers short-circuit +// remote work (upgrade self-update, event-bus peer connect) without +// each re-reading the flag. +func networkAllowed() bool { return !offline } + +// wireBusNetwork attaches a NetworkAdapter to the in-process bus so the +// domain events foo publishes reach external subscribers (aps, ctxt, +// tlc) over WebSocket. A bare bus.New() publishes to nobody; the adapter +// subscribes to every local topic ("#") and forwards to each connected +// peer. +// +// Peers are read from FOO_BUS_PEERS (comma-separated ws:// URLs); +// connects are best-effort (kit retries with backoff). When --offline +// is set, or no peers are configured, the adapter is skipped and events +// stay in-process. An auth token from FOO_BUS_TOKEN / BUS_TOKEN is +// attached when present. +func wireBusNetwork(ctx context.Context) { + if !networkAllowed() || eventBus == nil { + return + } + raw := strings.TrimSpace(os.Getenv("FOO_BUS_PEERS")) + if raw == "" { + return + } + var opts []kitbus.NetworkOption + if auth, ok := kitbus.AuthFromEnv("FOO_BUS_TOKEN", "BUS_TOKEN"); ok { + opts = append(opts, kitbus.WithAuth(auth)) + } + busNet = kitbus.NewNetworkAdapter(eventBus, opts...) + for _, addr := range strings.Split(raw, ",") { + addr = strings.TrimSpace(addr) + if addr == "" { + continue + } + if err := busNet.Connect(ctx, addr); err != nil { + slog.Warn("bus.network.connect.failed", slog.String("addr", addr), slog.Any("err", err)) + } + } +} + func publishEvent(ctx context.Context, topic string, payload any) { if eventBus == nil { return @@ -898,33 +1042,37 @@ func providerAuthRequirement(scheme string) (key string, authType string) { // pointer, and `pattern import` syntax for promoting project-local // patterns. Falls back to the original error if listing fails — the // hint is best-effort, never blocking. +// +// Returns a typed output.NotFoundError (exit code 3) so main.go maps +// the failure to the semantic not-found exit status. func enrichPatternNotFound(want string, orig error) error { names, listErr := pattern.List(cfg.PatternsPath) if listErr != nil || len(names) == 0 { - return fmt.Errorf("%w; run `foo pattern list` to see available patterns, or `foo pattern import %s` to promote a project-local pattern", orig, want) + return output.NotFoundError(fmt.Sprintf("%s; run `foo pattern list` to see available patterns, or `foo pattern import %s` to promote a project-local pattern", orig, want)) } if guess := suggest.Closest(want, names, 2); guess != "" { - return fmt.Errorf("%w; did you mean %q? (run `foo pattern list` to see all)", orig, guess) + return output.NotFoundError(fmt.Sprintf("%s; did you mean %q? (run `foo pattern list` to see all)", orig, guess)) } - return fmt.Errorf("%w; available patterns: %s (run `foo pattern import %s` to add a project-local pattern globally)", orig, strings.Join(names, ", "), want) + return output.NotFoundError(fmt.Sprintf("%s; available patterns: %s (run `foo pattern import %s` to add a project-local pattern globally)", orig, strings.Join(names, ", "), want)) } // enrichSchemaNotFound wraps the schema lookup failure with a closest // suggestion, falling back to a list of available schemas. Errors from -// listing are silent; the hint is best-effort. +// listing are silent; the hint is best-effort. Returns a typed +// output.NotFoundError (exit code 3). func enrichSchemaNotFound(want string, orig error) error { store, err := openSchemaStore() if err != nil { - return orig + return output.NotFoundError(orig.Error()) } names, listErr := store.List() if listErr != nil || len(names) == 0 { - return fmt.Errorf("%w; run `foo schema list` to see stored schemas, or supply a valid DSL string like \"name, age int\"", orig) + return output.NotFoundError(fmt.Sprintf("%s; run `foo schema list` to see stored schemas, or supply a valid DSL string like \"name, age int\"", orig)) } if guess := suggest.Closest(want, names, 2); guess != "" { - return fmt.Errorf("%w; did you mean %q? (run `foo schema list` to see all)", orig, guess) + return output.NotFoundError(fmt.Sprintf("%s; did you mean %q? (run `foo schema list` to see all)", orig, guess)) } - return fmt.Errorf("%w; available schemas: %s", orig, strings.Join(names, ", ")) + return output.NotFoundError(fmt.Sprintf("%s; available schemas: %s", orig, strings.Join(names, ", "))) } type patternRow struct { diff --git a/cmd/foo/commands/schema.go b/cmd/foo/commands/schema.go index e90d6d1..4c222a8 100644 --- a/cmd/foo/commands/schema.go +++ b/cmd/foo/commands/schema.go @@ -12,8 +12,14 @@ import ( "hop.top/foo/internal/schema" kitcli "hop.top/kit/go/console/cli" "hop.top/kit/go/core/xdg" + "hop.top/kit/go/storage/sqlstore" ) +// schemaSchemaVersion is the schema-store revision recorded in +// pre-migrate backup filenames. Bump when schema.NewStore's table +// layout changes. +const schemaSchemaVersion = 1 + func schemaCmd() *cobra.Command { cmd := &cobra.Command{ Use: "schema", @@ -75,7 +81,7 @@ func schemaShowCmd() *cobra.Command { sc, err := store.Get(args[0]) if err != nil { - return err + return enrichSchemaNotFound(args[0], err) } return renderData(cmd, schemaView{Name: sc.Name, DSL: sc.DSL, Schema: sc.Schema}) @@ -111,6 +117,7 @@ persistence; the JSON path stores the schema verbatim.`, if _, err := store.SetJSON(name, json.RawMessage(data)); err != nil { return err } + publishEvent(cmd.Context(), "foo.knowledge.schema.created", map[string]any{"name": name, "source": filePath}) fmt.Fprintf(cmd.OutOrStdout(), "schema %q saved from %s\n", name, filePath) return nil } @@ -122,6 +129,7 @@ persistence; the JSON path stores the schema verbatim.`, if _, err := store.Set(name, args[1]); err != nil { return err } + publishEvent(cmd.Context(), "foo.knowledge.schema.created", map[string]any{"name": name}) fmt.Fprintf(cmd.OutOrStdout(), "schema %q saved\n", name) return nil }, @@ -145,8 +153,9 @@ func schemaDeleteCmd() *cobra.Command { } if err := store.Remove(args[0]); err != nil { - return err + return enrichSchemaNotFound(args[0], err) } + publishEvent(cmd.Context(), "foo.knowledge.schema.deleted", map[string]any{"name": args[0]}) fmt.Fprintf(cmd.OutOrStdout(), "schema %q deleted\n", args[0]) return nil }, @@ -166,6 +175,7 @@ func schemaCompileCmd() *cobra.Command { if err != nil { return err } + publishEvent(cmd.Context(), "foo.knowledge.schema.compiled", map[string]any{"dsl": args[0]}) return renderData(cmd, schemaView{Name: "", DSL: args[0], Schema: compiled}) }, } @@ -184,6 +194,14 @@ func openSchemaStore() (*schema.Store, error) { } dbPath := filepath.Join(stateDir, "schemas.db") + + // Back up the live DB into /.dbs/ before NewStore migrates. + // No-op on first run; timestamped copy otherwise. Backups stay in a + // hidden .dbs sibling, never beside the live DB. + if _, err := sqlstore.BackupBeforeMigrate(dbPath, schemaSchemaVersion, sqlstore.WithBackupDir(filepath.Join(stateDir, ".dbs"))); err != nil { + return nil, fmt.Errorf("backup schemas db: %w", err) + } + db, err := sql.Open("sqlite3", dbPath) if err != nil { return nil, fmt.Errorf("open db: %w", err) diff --git a/docs/reference/event-topics.md b/docs/reference/event-topics.md new file mode 100644 index 0000000..020e12f --- /dev/null +++ b/docs/reference/event-topics.md @@ -0,0 +1,69 @@ +# Event topic namespace policy + +Canonical topic shape for every event the foo binaries publish to +`kit/bus`. One policy across all three binaries so sibling tools +(aps, ctxt, tlc) can subscribe predictably. + +## Shape + +``` +[source].[category].[object].[action] +``` + +Four lowercase, dot-separated segments. `action` is past-tense (the +event already happened). This is the house event-bus notation; it +refines the 3-part `..` form in the CLI conventions +doc (§9) by inserting an explicit `category` segment that maps to the +binary's help-group taxonomy (§4). + +| Segment | Meaning | Source of truth | +|---|---|---| +| `source` | publishing binary | `foo`, `foo-scrape`, `foo-youtube` | +| `category` | domain group | the command's help GroupID (§4): `knowledge`, `organize`, `capture`, … | +| `object` | the noun acted on | `pattern`, `schema`, `fragment`, `model`, `embedding`, `page`, `transcript` | +| `action` | past-tense verb | `created`, `updated`, `deleted`, `compiled`, `scraped`, `fetched` | + +## Registry + +### foo (host) + +| Mutation | Topic | +|---|---| +| `pattern create` | `foo.knowledge.pattern.created` | +| `pattern delete` | `foo.knowledge.pattern.deleted` | +| `pattern import` | `foo.knowledge.pattern.imported` | +| `fragment create` | `foo.knowledge.fragment.created` | +| `fragment delete` | `foo.knowledge.fragment.deleted` | +| `schema create` | `foo.knowledge.schema.created` | +| `schema delete` | `foo.knowledge.schema.deleted` | +| `schema compile` | `foo.knowledge.schema.compiled` | +| `embed add` / `embed file` | `foo.knowledge.embedding.created` | +| `embed collection delete` | `foo.knowledge.collection.deleted` | +| `model default` | `foo.organize.model.selected` | +| `provider` mutations | `foo.organize.provider.` | + +### foo-scrape (sidecar) + +| Mutation | Topic | +|---|---| +| URL scraped | `foo-scrape.capture.page.scraped` | + +### foo-youtube (sidecar) + +| Mutation | Topic | +|---|---| +| transcript extracted | `foo-youtube.capture.transcript.fetched` | +| metadata extracted | `foo-youtube.capture.metadata.fetched` | + +## Rules + +- `category` is taken from the command's existing GroupID — do not + invent a new taxonomy for events. Sidecars whose sole job is + capture use `capture`. +- `action` is always past-tense. A read (`list`, `show`, `search`) + publishes nothing — only state mutations emit. +- The network adapter (`bus.NewNetworkAdapter`) MUST be wired for any + of these to reach an external subscriber; a bare in-process + `bus.New()` publishes to nobody. (See the host's `initializeRuntime`.) +- Payload carries the object's id + the actor; never the full body of + a secret-bearing object. diff --git a/internal/embed/openai.go b/internal/embed/openai.go index 9e0efae..b666f2f 100644 --- a/internal/embed/openai.go +++ b/internal/embed/openai.go @@ -8,6 +8,10 @@ import ( "io" "net/http" "os" + + "hop.top/kit/go/console/output" + "hop.top/kit/go/storage/secret" + _ "hop.top/kit/go/storage/secret/env" ) const ( @@ -38,11 +42,13 @@ func WithDimension(dim int) OpenAIOption { } // NewOpenAIEmbedder creates a new OpenAI-backed Embedder. -// Uses OPENAI_API_KEY from the environment. +// The OpenAI API key is resolved through the kit secret store (default +// "env" backend), so it reads OPENAI_API_KEY from the environment by +// default but transparently honors a configured keychain/vault backend. func NewOpenAIEmbedder(opts ...OpenAIOption) (*OpenAIEmbedder, error) { - key := os.Getenv("OPENAI_API_KEY") + key := lookupOpenAIKey() if key == "" { - return nil, fmt.Errorf("OPENAI_API_KEY not set") + return nil, output.UnauthorizedError("OPENAI_API_KEY not set") } e := &OpenAIEmbedder{ @@ -57,6 +63,20 @@ func NewOpenAIEmbedder(opts ...OpenAIOption) (*OpenAIEmbedder, error) { return e, nil } +// lookupOpenAIKey resolves the OpenAI API key via the kit secret store. +// The default "env" backend maps secret key `openai_api_key` to env var +// OPENAI_API_KEY, preserving prior behavior; a store-open failure falls +// back to a direct env read so an env-only setup never regresses. +func lookupOpenAIKey() string { + store, err := secret.Open(secret.Config{Backend: "env"}) + if err == nil { + if got, getErr := store.Get(context.Background(), "openai_api_key"); getErr == nil { + return string(got.Value) + } + } + return os.Getenv("OPENAI_API_KEY") +} + func (e *OpenAIEmbedder) Dimension() int { return e.dim } type embeddingReq struct { diff --git a/internal/llm/llm.go b/internal/llm/llm.go index 46579b8..9b8faf0 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -17,6 +17,9 @@ import ( _ "hop.top/kit/go/ai/llm/ollama" _ "hop.top/kit/go/ai/llm/openai" _ "hop.top/kit/go/ai/llm/routellm" + "hop.top/kit/go/console/output" + "hop.top/kit/go/storage/secret" + _ "hop.top/kit/go/storage/secret/env" ) type Client struct { @@ -157,9 +160,9 @@ func NewClient(ctx context.Context, opts ClientOpts) (*Client, error) { func buildClient(scheme, model, envVar string) (*Client, error) { var uri string if envVar != "" { - key := os.Getenv(envVar) + key := lookupAPIKey(envVar) if key == "" { - return nil, fmt.Errorf("missing %s for model %q (provider %s); export %s=... and retry, or switch models with `foo model default `", envVar, model, scheme, envVar) + return nil, output.UnauthorizedError(fmt.Sprintf("missing %s for model %q (provider %s); export %s=... and retry, or switch models with `foo model default `", envVar, model, scheme, envVar)) } uri = fmt.Sprintf("%s://%s?api_key=%s", scheme, model, key) } else { @@ -191,6 +194,29 @@ func buildClient(scheme, model, envVar string) (*Client, error) { }, nil } +// lookupAPIKey resolves a provider API key through the kit secret store +// rather than reading the process environment directly. The store is +// opened with the default "env" backend, so the historical behavior is +// preserved: secret key `openai_api_key` maps to env var +// `OPENAI_API_KEY` (the env backend uppercases and swaps `/`→`_`). +// Configuring a different backend (keychain, vault) in foo's config +// transparently redirects the lookup without touching this call site. +// +// envVar is the canonical env-var spelling (e.g. "OPENAI_API_KEY"); it +// is lowercased to form the backend-neutral secret key. A direct +// os.Getenv read is the last-resort fallback so a store-open failure +// never regresses a working env-based setup. +func lookupAPIKey(envVar string) string { + key := strings.ToLower(envVar) + store, err := secret.Open(secret.Config{Backend: "env"}) + if err == nil { + if got, getErr := store.Get(context.Background(), key); getErr == nil { + return string(got.Value) + } + } + return os.Getenv(envVar) +} + // schemeForModel maps a model id to its kit URI scheme and the env var // that holds the provider's key. Empty envVar = local provider (no // precheck). The router- prefix returns "routellm" so the caller knows diff --git a/main.go b/main.go index 2929b3a..1a7ae9c 100644 --- a/main.go +++ b/main.go @@ -2,9 +2,11 @@ package main import ( "context" + "errors" "os" "hop.top/foo/cmd/foo/commands" + "hop.top/kit/go/console/output" ) var version = "dev" @@ -12,6 +14,23 @@ var version = "dev" func main() { root := commands.New(version) if err := root.Execute(context.Background()); err != nil { - os.Exit(1) + os.Exit(exitCode(err)) } } + +// exitCode maps a returned error to a process exit status. kit's +// Root.Execute returns the error rather than os.Exit-ing the semantic +// code, so the adopter owns the mapping: any error carrying an +// *output.Error (via the AsCLIError convention) exits with its +// ExitCode (3 not-found, 4 conflict, 5 unauthorized, …); everything +// else falls back to the generic 1. +func exitCode(err error) int { + type cliError interface{ AsCLIError() *output.Error } + var ce cliError + if errors.As(err, &ce) { + if e := ce.AsCLIError(); e != nil && e.ExitCode != 0 { + return e.ExitCode + } + } + return 1 +}