diff --git a/.claude/skills/forge.md b/.claude/skills/forge.md index 24042fd3..dd80af9e 100644 --- a/.claude/skills/forge.md +++ b/.claude/skills/forge.md @@ -887,6 +887,7 @@ Full reference: `docs/reference/cli-reference.md`. | Subcommand | Purpose | Key flags | |---|---|---| +| `forge try` | Instant demo: scaffold a keyless demo agent (native executor + `weather` skill + safe builtins) into a temp dir and chat in-process with the tool/egress loop rendered inline. No server/build. Credential auto-resolved (flags → env key → OpenAI OAuth → Ollama → picker); nothing on disk unless `--keep`. Same runtime as `forge run`, no A2A server | `--provider`, `--model`, `--once `, `--keep`, `--quiet`, `--audit` | | `forge init` | Scaffold a new agent: `forge.yaml`, `.env`, `SKILL.md`, `guardrails.json`. Interactive TUI by default; `--non-interactive` for CI | `--model-provider`, `--model-name`, `--channels`, `--auth`, `--from-skills`, `--compression` | | `forge build` | Run the build pipeline → `.forge-output/agent.json` + container Dockerfile + K8s manifests + (optional) signature | `--output-dir`, `--sign` | | `forge validate` | Lint `forge.yaml` + SKILL.md. `--platform-policy=PATH` lints a policy file standalone | `--strict`, `--command-compat`, `--platform-policy` | @@ -899,7 +900,7 @@ Full reference: `docs/reference/cli-reference.md`. | `forge tool list \| describe` | Enumerate registered tools, show schemas | | | `forge channel add \| list \| status \| serve \| disable \| enable` | Channel adapters; disable/enable edit the user policy layer by default; `--system` retargets `/etc/forge/policy.yaml` | `--with`, `--system` | | `forge secret set \| get \| list \| delete` | Encrypted secrets | | -| `forge auth show-token \| mint-token \| secret-yaml` | Operator UX for the internal bearer token at `/.forge/runtime.token` (same token channel adapters + K8s CronJob trigger pods use). `secret-yaml` prints a ready-to-apply K8s Secret manifest sourced from the local token; `mint-token` is for first-deploy bootstrap. `forge.agent.id` label always tracks `forge.yaml` `agent_id`, never the `--name` override. (#162 part 1, PR #168) | `--namespace`, `--name` | +| `forge auth show-token \| mint-token \| secret-yaml \| logout` | Operator UX for the internal bearer token at `/.forge/runtime.token` (same token channel adapters + K8s CronJob trigger pods use). `secret-yaml` prints a ready-to-apply K8s Secret manifest sourced from the local token; `mint-token` is for first-deploy bootstrap. `logout [provider]` clears a stored LLM OAuth credential (default openai) so the next `forge init`/`forge try` re-prompts sign-in — laptop/dev only, **refuses inside an agent runtime** (container or `FORGE_PLATFORM_TOKEN` set). `forge.agent.id` label always tracks `forge.yaml` `agent_id`, never the `--name` override. (#162 part 1, PR #168) | `--namespace`, `--name` | | `forge key generate \| sign \| verify` | Ed25519 build artifact signing | | | `forge skills add \| list \| validate \| audit` | Registry: install, search, validate binary/env deps, security audit `--embedded` | `--category`, `--tags`, `--embedded` | | `forge mcp list \| test \| login \| logout` | Manage MCP servers + OAuth tokens | `--call `, `--args ''` | @@ -1033,6 +1034,11 @@ guardrails_path: guardrails.json ## 15. How to create an agent +For a first taste, `forge try` scaffolds a keyless demo agent and drops you into +a chat with the tool/egress loop rendered inline (ephemeral by default; +`forge try --keep` writes it to `./forge-quickstart`). The pipeline below is for +building your own from scratch. + ```bash # 1. Scaffold forge init my-agent --model-provider anthropic --channels slack --non-interactive diff --git a/README.md b/README.md index 540ddad9..c17c98e0 100644 --- a/README.md +++ b/README.md @@ -23,19 +23,31 @@ Forge is the open-source runtime for Anthropic's Agent Skills standard — built ## Quick Start +Talk to a working agent, and watch it use a tool, in under 60 seconds: + ```bash -# Install (pick one) -brew install initializ/tap/forge -curl -sSL https://raw.githubusercontent.com/initializ/forge/main/install.sh | bash +brew install initializ/tap/forge && forge try +``` + +`forge try` scaffolds a keyless demo agent, finds whatever model credential you +already have, and drops you into a chat whose every tool call and egress check +renders inline: -# Create and run an agent -forge init my-agent && cd my-agent && forge run +``` +you › what's the weather in Tokyo, should I pack an umbrella? + + ▸ tool weather_current(location=Tokyo) + ▸ egress wttr.in ✓ allowed + ◂ 18C, light rain this evening -# Connect to Slack -forge run --with slack +agent › 18C in Tokyo with light rain tonight. Yes, take the umbrella. ``` -See [Quick Start](docs/getting-started/quick-start.md) for the full walkthrough, or [Installation](docs/getting-started/installation.md) for all methods. +Then build your own: `forge try --keep` graduates the demo to `./forge-quickstart`. + +See [Quick Start](docs/getting-started/quick-start.md) for the walkthrough, +[Ship to Production](docs/getting-started/ship-to-production.md) for the full +pipeline, or [Installation](docs/getting-started/installation.md) for all methods. ## How It Works @@ -73,7 +85,8 @@ You write a `SKILL.md`. Forge compiles it into a secure, runnable agent with egr | Document | Description | |----------|-------------| -| [Quick Start](docs/getting-started/quick-start.md) | Get an agent running in 60 seconds | +| [Quick Start](docs/getting-started/quick-start.md) | Talk to an agent in 60 seconds with `forge try` | +| [Ship to Production](docs/getting-started/ship-to-production.md) | The full init, build, package, deploy pipeline | | [Installation](docs/getting-started/installation.md) | Homebrew, binary, and Windows install | | [Architecture](docs/core-concepts/how-forge-works.md) | System design, module layout, and data flows | diff --git a/docs/core-concepts/runtime-engine.md b/docs/core-concepts/runtime-engine.md index 131de0fc..3af7fadf 100644 --- a/docs/core-concepts/runtime-engine.md +++ b/docs/core-concepts/runtime-engine.md @@ -211,6 +211,16 @@ Executor selection happens in `runner.go` based on framework type and configurat ## Running Modes +### `forge try` — In-Process Demo + +`forge try` runs the demo agent **in-process** — no HTTP server, daemon, or port binding. It builds the same `LLMExecutor` `forge run` uses (built-in tool registry, egress-enforced client + subprocess proxy, audit + progress hooks, provider client) via a trimmed bootstrap (`forge-cli/runtime/local_session.go`) and drives it from a stdin REPL, one turn per line, with conversation history held in memory (the executor `Store` is nil, so nothing persists). It's the 60-second on-ramp; see the [Quick Start](../getting-started/quick-start.md) and the [`forge try` CLI reference](../reference/cli-reference.md#forge-try). + +```bash +forge try # interactive +forge try --once "what's 17% of 4,200?" # one-shot +forge try --keep # graduate to ./forge-quickstart +``` + ### `forge run` — Foreground Server Run the agent as a foreground HTTP server. Used for development and container deployments. diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 0f76bf3c..d5a8884f 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -1,51 +1,83 @@ --- title: "Quick Start" -description: "Get a Forge agent running in under 60 seconds." +description: "Talk to a working agent in under 60 seconds with forge try." order: 2 --- -Get a Forge agent running in under 60 seconds. +Talk to a working agent, and watch it use a tool, in under 60 seconds. One command: -## Why Forge? +```bash +brew install initializ/tap/forge && forge try +``` -**Instant Agent From a Single Command** +No build, no cluster, no config. `forge try` scaffolds a keyless demo agent into +a throwaway workspace, finds whatever model credential you already have (an env +key, an OpenAI sign-in, or a local Ollama), and drops you into a chat whose every +tool call and egress check renders inline. -Write a SKILL.md. Run `forge init`. Your agent is live. +## What it looks like -The wizard configures your model provider, validates your API key, -connects Slack or Telegram, picks skills, and starts your agent. -Zero to running in under 60 seconds. +``` +$ forge try -**Secure by Default** + forge try: talking to a live agent in your terminal. + No build, no cluster. Ctrl-D or /exit to quit. -Forge is designed for safe execution: + Using OpenAI (signed in). + Agent: quickstart · skills: weather · tools: http_request, datetime_now, math_calculate -* Does NOT create public tunnels -* Does NOT expose webhooks automatically -* Uses outbound-only connections (Slack Socket Mode, Telegram polling) -* Enforces outbound domain allowlists at both build-time and runtime, including subprocess HTTP via a local egress proxy -* Encrypts secrets at rest (AES-256-GCM) with per-agent isolation -* Signs build artifacts (Ed25519) for supply chain integrity -* Supports restricted network profiles with audit logging + Try: what's the weather in Tokyo? + what's 17% of 4,200? + what time is it in UTC? -No accidental exposure. No hidden listeners. +you › what's the weather in Tokyo, should I pack an umbrella? -## Get Started in 60 Seconds + ▸ tool weather_current(location=Tokyo) + ▸ egress wttr.in ✓ allowed + ◂ 18C, light rain this evening -```bash -# Install -curl -sSL https://raw.githubusercontent.com/initializ/forge/main/install.sh | bash +agent › 18C in Tokyo with light rain expected tonight. Yes, take the umbrella. -# Initialize a new agent (interactive wizard) -forge init my-agent + audit {"tools":["weather_current"],"egress":["wttr.in:allow"]} -# Run locally -cd my-agent && forge run +you › ^D -# Run with Telegram -forge run --with telegram + You just ran an agent whose every tool call and egress you can see and audit. + Want to keep it and make it yours? -> forge try --keep (writes ./forge-quickstart) + Then edit skills/, run it as a service with forge serve, or deploy with forge package. ``` -The `forge init` wizard walks you through model provider, API key, fallback providers, tools, skills, and channel setup. Use `--non-interactive` with flags for scripted setups. +The inline `▸ tool` / `▸ egress` lines are the agent's own audit stream, the same +signal the enterprise story rests on, surfaced as the first-run view. Add `--audit` +to see the full NDJSON, or `--quiet` to hide the loop. + +## One-shot mode + +For a non-interactive taste (CI, docs, a quick check): + +```bash +forge try --once "what's 2 + 2?" +``` + +## No credential yet? + +`forge try` resolves, in order: an explicit `--provider`/`--model`, then an env +key (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY`), then a saved +OpenAI sign-in, then a local [Ollama](https://ollama.com) daemon. With none of +those, it offers a one-time picker (sign in with OpenAI, paste a key, or use +Ollama). Nothing is written to disk unless you pass `--keep`. + +## The ladder + +`forge try` is rung one. Each step adds one capability: + +1. **60s** — `forge try`: talk to an agent, watch it use a tool. +2. **5 min** — `forge try --keep`, then edit `skills//SKILL.md` and add a skill. See [Your First Skill](your-first-skill.md). +3. **15 min** — `forge serve`, tail the audit log, add a guardrail and watch it block. See [Ship to Production](ship-to-production.md). +4. **Ship** — `forge package`: an egress-enforced container into your own cluster. See [Ship to Production](ship-to-production.md). + +## Next steps -See [Installation](installation.md) for all installation methods. +- [Your First Skill](your-first-skill.md) — teach the agent something new. +- [Ship to Production](ship-to-production.md) — the full init -> build -> package -> deploy pipeline. +- [Installation](installation.md) — every install method. diff --git a/docs/getting-started/ship-to-production.md b/docs/getting-started/ship-to-production.md new file mode 100644 index 00000000..d9396733 --- /dev/null +++ b/docs/getting-started/ship-to-production.md @@ -0,0 +1,95 @@ +--- +title: "Ship to Production" +description: "The full pipeline: init, skills, secrets, validate, build, package, deploy." +order: 3 +--- + +Once [`forge try`](quick-start.md) has shown you a working agent, this is the +path to a deployable one you own: scaffold it, give it skills and secrets, +validate, build, and package it into an egress-enforced container for your own +cluster. Each step is independently runnable. + +## 1. Scaffold + +```bash +forge init my-agent +cd my-agent +``` + +The interactive wizard configures the model provider, validates the API key, +optionally connects a channel (Slack / Telegram), picks skills, and sets the +egress allowlist. For scripted setups, use flags with `--non-interactive`: + +```bash +forge init my-agent --model-provider anthropic --non-interactive +``` + +`forge try --keep` writes the same layout to `./forge-quickstart`, so you can +graduate the demo agent instead of starting from scratch. + +## 2. Add skills + +Skills are the agent's capabilities. Install from the registry or write your own: + +```bash +forge skills add tavily-research # registry skill +# or author skills//SKILL.md by hand +``` + +See [Your First Skill](your-first-skill.md) for the SKILL.md format. + +## 3. Configure secrets + +Secrets are encrypted at rest (AES-256-GCM), per-agent: + +```bash +forge secret set ANTHROPIC_API_KEY sk-... +forge secret set SLACK_BOT_TOKEN xoxb-... +``` + +## 4. Validate + +Catch config, egress, and policy problems before building: + +```bash +forge validate +``` + +## 5. Run locally + +```bash +forge run # A2A server on :8080 +forge serve # long-running service +forge run --with slack # attach a channel +``` + +Tail the audit log while it runs to see tool calls, egress decisions, and +guardrail blocks, the same stream `forge try` renders inline. + +## 6. Build + +Compile the agent and its dependencies into a runnable artifact. Build-time +egress allowlisting and Ed25519 artifact signing happen here: + +```bash +forge build +``` + +## 7. Package and deploy + +Produce an egress-enforced container image and the deployment manifests for your +cluster: + +```bash +forge package +``` + +The generated image enforces the outbound domain allowlist at runtime (including +subprocess HTTP via the local egress proxy), so the deployed agent has the same +network posture you validated locally. Deploy it with your own pipeline. + +## Related + +- [Quick Start](quick-start.md) — the 60-second `forge try` on-ramp. +- [Your First Skill](your-first-skill.md) — author a SKILL.md. +- [Installation](installation.md) — every install method. diff --git a/docs/reference/cli-reference.md b/docs/reference/cli-reference.md index 306a8ab4..40df865b 100644 --- a/docs/reference/cli-reference.md +++ b/docs/reference/cli-reference.md @@ -126,6 +126,36 @@ See [Authentication](../security/authentication.md) for the full auth provider r --- +## `forge try` + +Talk to a working demo agent in your terminal in under 60 seconds — no build, no cluster, no config. Scaffolds a keyless demo agent (the native forge LLM executor + the `weather` skill + `http_request`/`datetime_now`/`math_calculate` builtins) into a throwaway workspace, resolves whatever model credential is available, and drops you into a chat whose tool calls and egress checks render inline. See the [Quick Start](../getting-started/quick-start.md) for the full walkthrough. + +```bash +# Interactive: resolves a credential, then chat +forge try + +# One-shot (CI / docs / a quick check) +forge try --once "what's 17% of 4,200?" + +# Keep the demo agent to make it your own (writes ./forge-quickstart) +forge try --keep +``` + +| Flag | Description | +|------|-------------| +| `--provider` | Model provider: `openai`, `anthropic`, `gemini`, or `ollama`. Skips auto-resolution. | +| `--model` | Model name (defaults to the provider's default). | +| `--once ` | Run a single prompt non-interactively, then exit. | +| `--keep` | Write the demo agent to `./forge-quickstart` instead of an auto-cleaned temp dir. | +| `--quiet` | Hide the inline tool/egress loop lines. | +| `--audit` | Show the full NDJSON audit event stream instead of the compact summary. | + +**Credential resolution order:** explicit `--provider`/`--model` → env key (`ANTHROPIC_API_KEY` → `OPENAI_API_KEY` → `GEMINI_API_KEY`) → saved OpenAI OAuth session → local [Ollama](https://ollama.com) daemon → interactive picker (sign in with OpenAI, paste a key, or use Ollama). Nothing is written to disk unless you pass `--keep`; a pasted key is held in memory only. Use [`forge auth logout`](#forge-auth) to clear a saved OAuth session and re-show the picker. + +The demo runs the same runtime as `forge run` (tool registry, egress enforcement, audit + progress hooks) in-process — no A2A server, daemon, or port binding. Exit with `/exit`, Ctrl-D, or Ctrl-C. + +--- + ## `forge compression` Inspect context compression state. @@ -532,10 +562,17 @@ forge auth secret-yaml --name custom-secret-name # Common one-liner: populate the Secret a `forge package` deploy # expects from the local runtime.token. forge auth secret-yaml | kubectl apply -f - + +# Remove a stored LLM OAuth credential (default: openai) so the next +# `forge init` / `forge try` prompts you to sign in again. +forge auth logout +forge auth logout openai ``` The `forge.agent.id` label on the generated Secret is always sourced from `forge.yaml`'s `agent_id` (or the `"forge-agent"` fallback), never from the `--name` override — so operators using `--name` to match an existing cluster convention still see telemetry and label-selectors keyed on the real agent ID. +`forge auth logout` is an operator/laptop command: it deletes the OAuth credential from `~/.forge/credentials` and the encrypted store, and **refuses to run inside an agent runtime** — a container, or when `FORGE_PLATFORM_TOKEN` is set. A deployed agent authenticates with an injected API key or platform token, not the OAuth credential store, so there is nothing there for the runtime to log out of; the refusal is defense-in-depth so Forge is never the tool an agent shells out to in order to wipe an operator's credential. + --- ## `forge key` diff --git a/forge-cli/cmd/auth.go b/forge-cli/cmd/auth.go index e72249bc..af99dd51 100644 --- a/forge-cli/cmd/auth.go +++ b/forge-cli/cmd/auth.go @@ -214,4 +214,5 @@ func init() { authCmd.AddCommand(authShowTokenCmd) authCmd.AddCommand(authMintTokenCmd) authCmd.AddCommand(authSecretYAMLCmd) + authCmd.AddCommand(authLogoutCmd) } diff --git a/forge-cli/cmd/auth_logout.go b/forge-cli/cmd/auth_logout.go new file mode 100644 index 00000000..f85b74c8 --- /dev/null +++ b/forge-cli/cmd/auth_logout.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/initializ/forge/forge-core/llm/oauth" + "github.com/initializ/forge/forge-core/security" +) + +// authLogoutCmd removes a stored LLM OAuth credential so the next sign-in +// prompts again (e.g. to re-show the `forge try` credential picker). +// +// It is deliberately an operator/laptop command and REFUSES to run inside an +// agent runtime (a container, or when FORGE_PLATFORM_TOKEN is injected). A +// deployed agent authenticates with an injected API key or platform token, not +// this OAuth credential store, so there is nothing here for the runtime to log +// out of — and Forge should not be the tool an agent shells out to in order to +// wipe an operator's credential from inside a sandbox. This is defense in +// depth, not the primary control: an agent that can run arbitrary commands +// could `rm` the file directly, so the real mitigation is denying the agent +// write access to the operator's ~/.forge. See PR #353 discussion. +var authLogoutCmd = &cobra.Command{ + Use: "logout [provider]", + Short: "Remove a stored LLM OAuth credential (laptop/dev only)", + Long: `Delete the stored OAuth credential for an LLM provider (default: openai) +from ~/.forge/credentials and the encrypted store, so the next +'forge init' / 'forge try' prompts you to sign in again. + +This is an operator/laptop command. It refuses to run inside an agent +runtime (a container, or when FORGE_PLATFORM_TOKEN is set): a deployed +agent authenticates with an injected API key or platform token, not +this OAuth file, so there is nothing there to log out of.`, + Args: cobra.MaximumNArgs(1), + RunE: runAuthLogout, + SilenceUsage: true, +} + +func runAuthLogout(cmd *cobra.Command, args []string) error { + provider := "openai" + if len(args) == 1 && strings.TrimSpace(args[0]) != "" { + provider = strings.ToLower(strings.TrimSpace(args[0])) + } + + // Validate against the known providers BEFORE the value reaches the + // credential store, whose provider->path mapping is + // filepath.Join(dir, provider+".json") with no sanitization — so an + // unchecked arg like "../../../../tmp/x" would delete /tmp/x.json. Same + // whitelist the paste-key path uses. + if _, ok := providerKeyEnv[provider]; !ok { + return fmt.Errorf("unknown provider %q (use openai, anthropic, or gemini)", provider) + } + + if reason, denied := deniedInAgentRuntime(); denied { + return fmt.Errorf("refusing to log out %s: %s. "+ + "`auth logout` is a laptop/dev command; a deployed agent uses an injected "+ + "key or platform token, not the OAuth credential store", provider, reason) + } + + out := cmd.OutOrStdout() + // Only report "nothing to do" when the store is DEFINITIVELY empty. On a + // read error (e.g. a corrupt token file) fall through to delete so logout + // still clears it, rather than silently leaving it in place. + if tok, err := oauth.LoadCredentials(provider); err == nil && tok == nil { + _, _ = fmt.Fprintf(out, "No %s credential stored; nothing to do.\n", provider) + return nil + } + if err := oauth.DeleteCredentials(provider); err != nil { + return fmt.Errorf("removing %s credentials: %w", provider, err) + } + _, _ = fmt.Fprintf(out, "Logged out of %s. The next sign-in will prompt again.\n", provider) + return nil +} + +// deniedInAgentRuntime reports whether the process looks like a deployed agent +// runtime (a container, or a managed deployment with a platform token) rather +// than an operator's laptop, along with a human reason. Sensitive operator-only +// auth commands refuse there. +func deniedInAgentRuntime() (reason string, denied bool) { + if os.Getenv("FORGE_PLATFORM_TOKEN") != "" { + return "FORGE_PLATFORM_TOKEN is set (managed deployment)", true + } + if security.InContainer() { + return "running inside a container", true + } + return "", false +} diff --git a/forge-cli/cmd/auth_logout_test.go b/forge-cli/cmd/auth_logout_test.go new file mode 100644 index 00000000..39e82339 --- /dev/null +++ b/forge-cli/cmd/auth_logout_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/initializ/forge/forge-core/llm/oauth" +) + +// TestAuthLogout_RemovesCredential writes a fake OAuth credential to an isolated +// dir and confirms `auth logout` deletes it. +func TestAuthLogout_RemovesCredential(t *testing.T) { + dir := t.TempDir() + oauth.SetCredentialsDir(dir) + t.Cleanup(func() { oauth.SetCredentialsDir("") }) + t.Setenv("FORGE_PLATFORM_TOKEN", "") // ensure the guard doesn't trip + + if err := oauth.SaveCredentials("openai", &oauth.Token{AccessToken: "tok", RefreshToken: "r"}); err != nil { + t.Fatalf("SaveCredentials: %v", err) + } + if tok, _ := oauth.LoadCredentials("openai"); tok == nil { + t.Fatal("precondition: credential should exist") + } + + var out strings.Builder + authLogoutCmd.SetOut(&out) + if err := runAuthLogout(authLogoutCmd, []string{"openai"}); err != nil { + t.Fatalf("runAuthLogout: %v", err) + } + if tok, _ := oauth.LoadCredentials("openai"); tok != nil { + t.Error("credential should be gone after logout") + } + if !strings.Contains(out.String(), "Logged out of openai") { + t.Errorf("output = %q, want a logged-out message", out.String()) + } +} + +// TestAuthLogout_NothingToDo reports cleanly when no credential is stored. +func TestAuthLogout_NothingToDo(t *testing.T) { + oauth.SetCredentialsDir(t.TempDir()) + t.Cleanup(func() { oauth.SetCredentialsDir("") }) + t.Setenv("FORGE_PLATFORM_TOKEN", "") + + var out strings.Builder + authLogoutCmd.SetOut(&out) + if err := runAuthLogout(authLogoutCmd, nil); err != nil { + t.Fatalf("runAuthLogout: %v", err) + } + if !strings.Contains(out.String(), "nothing to do") { + t.Errorf("output = %q, want a nothing-to-do message", out.String()) + } +} + +// TestAuthLogout_RejectsTraversalProvider pins that an unknown / path-traversal +// provider arg is rejected before it reaches the credential store's +// provider->path mapping (which would otherwise delete .json anywhere). +func TestAuthLogout_RejectsTraversalProvider(t *testing.T) { + oauth.SetCredentialsDir(t.TempDir()) + t.Cleanup(func() { oauth.SetCredentialsDir("") }) + t.Setenv("FORGE_PLATFORM_TOKEN", "") + + for _, bad := range []string{"../../../../tmp/x", "openai/../../etc/x", "google", "x"} { + err := runAuthLogout(authLogoutCmd, []string{bad}) + if err == nil || !strings.Contains(err.Error(), "unknown provider") { + t.Errorf("logout %q: want unknown-provider rejection, got %v", bad, err) + } + } +} + +// TestAuthLogout_RefusesInAgentRuntime is the guard: with a platform token set +// (a managed deployment), logout refuses and does NOT touch the credential. +func TestAuthLogout_RefusesInAgentRuntime(t *testing.T) { + dir := t.TempDir() + oauth.SetCredentialsDir(dir) + t.Cleanup(func() { oauth.SetCredentialsDir("") }) + if err := oauth.SaveCredentials("openai", &oauth.Token{AccessToken: "tok"}); err != nil { + t.Fatalf("SaveCredentials: %v", err) + } + t.Setenv("FORGE_PLATFORM_TOKEN", "platform-tok") + + err := runAuthLogout(authLogoutCmd, []string{"openai"}) + if err == nil { + t.Fatal("want a refusal error inside a managed deployment") + } + if !strings.Contains(err.Error(), "refusing to log out") { + t.Errorf("error = %q, want a refusal", err.Error()) + } + // The credential must remain untouched. + if tok, _ := oauth.LoadCredentials("openai"); tok == nil { + t.Error("logout must not delete the credential when refused") + } +} diff --git a/forge-cli/cmd/init.go b/forge-cli/cmd/init.go index 5500ce06..446155ec 100644 --- a/forge-cli/cmd/init.go +++ b/forge-cli/cmd/init.go @@ -47,6 +47,15 @@ type initOptions struct { AuthMethod string // "apikey" or "oauth" Compression bool // reversible context compression (ctxzip) — compression.enabled in forge.yaml + // OutputDir overrides the scaffold target directory. Empty falls back + // to ./ (the classic `forge init` layout). `forge try` sets + // this to an ephemeral temp dir (or ./forge-quickstart under --keep). + OutputDir string + // Preset marks a non-wizard scaffold (e.g. `forge try`). It stops + // scaffold() before the "Created …" banner and the auto-run of + // `forge run` — the caller drives the agent itself, in-process. + Preset bool + // A2A auth chain (from the wizard's Authentication step or CLI flags). AuthMode string // "", "none", "oidc", "http_verifier", "custom" AuthSettings map[string]any // provider-specific settings block @@ -695,6 +704,9 @@ func scaffold(opts *initOptions) error { normalizeCustomProvider(opts) dir := filepath.Join(".", opts.AgentID) + if opts.OutputDir != "" { + dir = opts.OutputDir + } // Check if directory already exists if !opts.Force { @@ -815,6 +827,12 @@ func scaffold(opts *initOptions) error { } } + // Preset scaffolds (e.g. `forge try`) drive the agent in-process; stop + // before the banner and the auto-run of `forge run` below. + if opts.Preset { + return nil + } + fmt.Printf("\nCreated agent project in ./%s\n", opts.AgentID) // Show channel-specific reminders @@ -1217,6 +1235,15 @@ func defaultModelNameForProvider(provider string) string { func buildEnvVars(opts *initOptions) []envVarEntry { var vars []envVarEntry + // Preset scaffolds (forge try) resolve the model credential at runtime + // from the OAuth store / process env / paste-key, so they write NO provider + // key. A placeholder here (`your-api-key-here`) would be loaded from .env, + // shadow the real credential, and get sent to the provider as an invalid + // key — the exact failure #350 review hit on a fresh OpenAI sign-in. + if opts.Preset { + return vars + } + // Provider key switch opts.ModelProvider { case "openai": diff --git a/forge-cli/cmd/root.go b/forge-cli/cmd/root.go index 568082fa..83f612ca 100644 --- a/forge-cli/cmd/root.go +++ b/forge-cli/cmd/root.go @@ -31,6 +31,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&themeOverride, "theme", "", "TUI color theme: dark, light, or auto") rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(tryCmd) rootCmd.AddCommand(validateCmd) rootCmd.AddCommand(buildCmd) rootCmd.AddCommand(runCmd) diff --git a/forge-cli/cmd/try.go b/forge-cli/cmd/try.go new file mode 100644 index 00000000..f87b6e13 --- /dev/null +++ b/forge-cli/cmd/try.go @@ -0,0 +1,622 @@ +package cmd + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + "os" + "os/signal" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/initializ/forge/forge-cli/config" + "github.com/initializ/forge/forge-cli/internal/tryview" + "github.com/initializ/forge/forge-cli/runtime" + "github.com/initializ/forge/forge-core/llm/oauth" + "github.com/initializ/forge/forge-core/types" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// forge try — the instant-gratification onboarding command. It scaffolds a +// keyless demo agent into a throwaway workspace, resolves whatever model +// credential is already available, and drops the user into a streaming chat +// whose tool calls and egress checks render inline. No build, no channels, no +// server, no secrets on disk. See issue #350. +var tryCmd = &cobra.Command{ + Use: "try [prompt]", + Short: "Talk to a live agent in your terminal: no build, no cluster", + Long: "Scaffold a keyless demo agent and chat with it in your terminal, watching every " + + "tool call and egress check as it runs. The workspace is ephemeral unless you pass " + + "--keep, which writes it to ./forge-quickstart so you can make it your own.", + Args: cobra.MaximumNArgs(1), + RunE: runTry, + SilenceUsage: true, // runtime errors (e.g. no credential) shouldn't dump flag help +} + +// tryFlags holds the parsed command flags for one `forge try` invocation. +type tryFlags struct { + keep bool + provider string + model string + once string + onceSet bool // whether --once was explicitly set (distinguishes "" from unset) + quiet bool + audit bool + prompt string // optional positional first prompt +} + +func init() { + tryCmd.Flags().Bool("keep", false, "keep the demo agent in ./forge-quickstart instead of a temp dir") + tryCmd.Flags().String("provider", "", "model provider: openai, anthropic, gemini, or ollama") + tryCmd.Flags().String("model", "", "model name (defaults to the provider's default)") + tryCmd.Flags().String("once", "", "run a single prompt non-interactively, then exit") + tryCmd.Flags().Bool("quiet", false, "hide the inline tool/egress loop lines") + tryCmd.Flags().Bool("audit", false, "show the full NDJSON audit event stream") +} + +func parseTryFlags(cmd *cobra.Command, args []string) tryFlags { + f := tryFlags{} + f.keep, _ = cmd.Flags().GetBool("keep") + f.provider, _ = cmd.Flags().GetString("provider") + f.model, _ = cmd.Flags().GetString("model") + f.once, _ = cmd.Flags().GetString("once") + f.onceSet = cmd.Flags().Changed("once") + f.quiet, _ = cmd.Flags().GetBool("quiet") + f.audit, _ = cmd.Flags().GetBool("audit") + if len(args) > 0 { + f.prompt = args[0] + } + return f +} + +func runTry(cmd *cobra.Command, args []string) error { + flags := parseTryFlags(cmd, args) + out := cmd.OutOrStdout() + color := term.IsTerminal(int(os.Stdout.Fd())) && os.Getenv("NO_COLOR") == "" + accent := tryAccent(color) + + interactive := term.IsTerminal(int(os.Stdin.Fd())) + printTryHeader(out, accent, color) + + res, err := resolveTryProvider(cmd.Context(), flags, os.Stdin, out, interactive, color) + if err != nil { + return err + } + _, _ = fmt.Fprintf(out, " %s\n", res.Label) + + dir, cleanup, err := tryWorkspace(flags.keep) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() + } + + opts := quickstartPreset(res.Provider, res.Model) + opts.OutputDir = dir + opts.Force = true // forge owns this dir (a temp dir, or ./forge-quickstart) + + if err := scaffold(opts); err != nil { + return err + } + + cfg, err := config.LoadForgeConfig(filepath.Join(dir, "forge.yaml")) + if err != nil { + return fmt.Errorf("loading scaffolded agent: %w", err) + } + + printTrySummary(cmd, cfg, opts) + + sess, err := runtime.NewLocalSession(cmd.Context(), runtime.LocalSessionOptions{ + Config: cfg, + WorkDir: dir, + EnvOverrides: res.EnvOverrides, + Verbose: verbose, // global -v/--verbose; un-silences the runner logger + }) + if err != nil { + return err + } + defer func() { _ = sess.Close() }() + + // Visible-loop renderer: inline tool/egress/guardrail lines from the + // agent's own audit stream. --quiet hides it; --audit shows full NDJSON. + renderer := tryview.New(out, flags.quiet, flags.audit, color) + sess.AuditLogger().AddSink(renderer) + + // Ctrl-C cancels the in-flight turn (executor cancels at the next + // iteration / tool boundary) and ends the loop. + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer stop() + + // --once: run a single turn (or, for an empty prompt, just load + exit). + if flags.onceSet { + if strings.TrimSpace(flags.once) != "" { + if err := tryOneTurn(ctx, sess, renderer, out, flags.once); err != nil { + return err + } + } + if flags.keep { + _, _ = fmt.Fprintf(out, "\n Kept the demo agent in %s\n", accent("./forge-quickstart")) + } + return nil + } + + printTrySuggestions(out) + if err := tryREPL(ctx, sess, renderer, cmd, flags); err != nil { + return err + } + printTryGraduation(out, accent, flags.keep) + return nil +} + +// tryOneTurn runs a single non-interactive turn and prints the reply, then the +// compact audit summary. +func tryOneTurn(ctx context.Context, sess *runtime.LocalSession, renderer *tryview.Renderer, out io.Writer, prompt string) error { + reply, err := sess.RunTurn(ctx, prompt, nil) + if err != nil { + return err + } + _, _ = fmt.Fprintf(out, "\nagent › %s\n", reply) + renderer.FlushSummary() + return nil +} + +// tryREPL reads one prompt per line and runs a turn each, keeping history in +// the session. It exits on /exit, /quit, Ctrl-D (EOF), or a cancelled context. +// An optional positional prompt seeds the first turn. The renderer prints the +// inline loop (via the audit sink) during each turn; the compact summary +// prints after the reply. +func tryREPL(ctx context.Context, sess *runtime.LocalSession, renderer *tryview.Renderer, cmd *cobra.Command, flags tryFlags) error { + out := cmd.OutOrStdout() + + // Read stdin on a goroutine so the prompt is cancellable: a blocking + // scanner.Scan() can't observe ctx, so Ctrl-C (via signal.NotifyContext) + // would otherwise hang at "you ›" until Enter. The goroutine leaks at most + // one pending read when we return, which is harmless as the process exits. + lines := make(chan string) + eof := make(chan struct{}) + go func() { + scanner := bufio.NewScanner(cmd.InOrStdin()) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + lines <- scanner.Text() + } + close(eof) + }() + + pending := strings.TrimSpace(flags.prompt) + for { + if pending == "" { + _, _ = fmt.Fprint(out, "\nyou › ") + select { + case <-ctx.Done(): + _, _ = fmt.Fprintln(out) + return nil // Ctrl-C + case <-eof: + return nil // Ctrl-D / EOF + case line := <-lines: + pending = strings.TrimSpace(line) + } + } + if pending == "" { + continue + } + if pending == "/exit" || pending == "/quit" { + break + } + reply, err := sess.RunTurn(ctx, pending, nil) + pending = "" + if err != nil { + if ctx.Err() != nil { + break // cancelled mid-turn + } + _, _ = fmt.Fprintf(out, "\n error: %v\n", err) + continue + } + _, _ = fmt.Fprintf(out, "\nagent › %s\n", reply) + renderer.FlushSummary() + } + return nil +} + +// tryWorkspace returns the scaffold target directory, plus a cleanup func for +// the ephemeral case (nil under --keep). The ephemeral agent lives in a leaf +// under a fresh temp dir so scaffold()'s not-exists check still holds, and the +// whole temp tree is removed on exit — no secrets ever persist. +func tryWorkspace(keep bool) (dir string, cleanup func(), err error) { + if keep { + return filepath.Join(".", "forge-quickstart"), nil, nil + } + base, err := os.MkdirTemp("", "forge-try-") + if err != nil { + return "", nil, fmt.Errorf("creating temp workspace: %w", err) + } + return filepath.Join(base, "quickstart"), func() { _ = os.RemoveAll(base) }, nil +} + +// quickstartPreset is the demo agent: the native forge LLM executor, the +// keyless weather skill, and a few safe builtins. Egress is auto-derived from +// the skill (allowlist, no dev-open); no channels, no long-term memory, no +// secrets. Preset stops scaffold() before its auto-run so `forge try` drives +// the loop in-process. +func quickstartPreset(provider, model string) *initOptions { + return &initOptions{ + Name: "quickstart", + AgentID: "quickstart", + Framework: "forge", + ModelProvider: provider, + CustomModel: model, // empty → provider default (buildTemplateData) + AuthMethod: "apikey", + BuiltinTools: []string{"http_request", "datetime_now", "math_calculate"}, + Skills: []string{"weather"}, + NonInteractive: true, + Preset: true, + EnvVars: map[string]string{}, + } +} + +// printTrySummary prints the one-line agent summary shown at startup, e.g. +// +// Agent: quickstart · skills: weather · tools: http_request, datetime_now, math_calculate +func printTrySummary(cmd *cobra.Command, cfg *types.ForgeConfig, opts *initOptions) { + out := cmd.OutOrStdout() + parts := []string{fmt.Sprintf("Agent: %s", cfg.AgentID)} + if len(opts.Skills) > 0 { + parts = append(parts, "skills: "+strings.Join(opts.Skills, ", ")) + } + if len(opts.BuiltinTools) > 0 { + parts = append(parts, "tools: "+strings.Join(opts.BuiltinTools, ", ")) + } + _, _ = fmt.Fprintf(out, " %s\n", strings.Join(parts, " · ")) +} + +// trySuggestions are the starter prompts shown before the interactive REPL. +// All three stay within the keyless egress allowlist (weather via wttr.in, +// plus the math + time builtins), so none dead-ends on a blocked domain. +var trySuggestions = []string{ + "what's the weather in Tokyo?", + "what's 17% of 4,200?", + "what time is it in UTC?", +} + +// forgeLogo renders the FORGE block wordmark with a vertical orange gradient +// (plain, uncolored, when color is off — a non-TTY / NO_COLOR). +func forgeLogo(color bool) string { + lines := []string{ + `███████╗ ██████╗ ██████╗ ██████╗ ███████╗`, + `██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝`, + `█████╗ ██║ ██║██████╔╝██║ ███╗█████╗ `, + `██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ `, + `██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗`, + `╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝`, + } + // Bright at the top, warm at the base — the forge-heat gradient. + shades := []string{"#fdba74", "#fb923c", "#f97316", "#f97316", "#ea580c", "#c2410c"} + var b strings.Builder + b.WriteByte('\n') + for i, ln := range lines { + row := " " + ln + if color { + row = lipgloss.NewStyle().Foreground(lipgloss.Color(shades[i])).Render(row) + } + b.WriteString(row) + b.WriteByte('\n') + } + return b.String() +} + +// printTryHeader prints the branded intro: the FORGE logo + tagline in a color +// terminal, or a compact one-liner when color is off. +func printTryHeader(out io.Writer, accent func(string) string, color bool) { + if color { + _, _ = fmt.Fprint(out, forgeLogo(true)) + _, _ = fmt.Fprintf(out, " %s\n\n", + tryDim(true)("talking to a live agent in your terminal. No build, no cluster. Ctrl-D or /exit to quit.")) + return + } + _, _ = fmt.Fprintf(out, "\n %s: talking to a live agent in your terminal.\n", accent("forge try")) + _, _ = fmt.Fprintf(out, " No build, no cluster. Ctrl-D or /exit to quit.\n\n") +} + +// printTrySuggestions lists the starter prompts. +func printTrySuggestions(out io.Writer) { + _, _ = fmt.Fprintf(out, "\n Try: %s\n", trySuggestions[0]) + for _, s := range trySuggestions[1:] { + _, _ = fmt.Fprintf(out, " %s\n", s) + } +} + +// printTryGraduation frames the delight on exit and points the user up the +// ladder — keep the demo, run it as a service, or package it. +func printTryGraduation(out io.Writer, accent func(string) string, kept bool) { + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintln(out, " You just ran an agent whose every tool call and egress you can see and audit.") + if kept { + _, _ = fmt.Fprintf(out, " It's yours in %s: edit skills/, run it with %s, or deploy with %s.\n", + accent("./forge-quickstart"), accent("forge serve"), accent("forge package")) + return + } + _, _ = fmt.Fprintf(out, " Want to keep it and make it yours? -> %s (writes ./forge-quickstart)\n", accent("forge try --keep")) + _, _ = fmt.Fprintf(out, " Then edit skills/, run it as a service with %s, or deploy with %s.\n", + accent("forge serve"), accent("forge package")) +} + +// tryAccent returns an orange styler for command tokens, or an identity +// function when color is disabled (non-TTY / NO_COLOR). +func tryAccent(color bool) func(string) string { + if !color { + return func(s string) string { return s } + } + st := lipgloss.NewStyle().Foreground(lipgloss.Color("#f97316")) + return func(s string) string { return st.Render(s) } +} + +// tryDim returns a muted-grey styler, or an identity function when color is off. +func tryDim(color bool) func(string) string { + if !color { + return func(s string) string { return s } + } + st := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + return func(s string) string { return st.Render(s) } +} + +// tryResolution is the outcome of credential auto-resolution: which provider +// and model to run, a human label for the startup summary, and any credential +// env vars to inject for the in-process run (paste-key picker path only). +type tryResolution struct { + Provider string + Model string + Label string + EnvOverrides map[string]string +} + +// errNoTryCredential is returned when no model credential is available and the +// session is non-interactive (CI), so no picker can be shown. +var errNoTryCredential = errors.New( + "no model credential found. Do one of:\n" + + " - set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY in your environment\n" + + " - run `forge try` in an interactive terminal to sign in with OpenAI\n" + + " - start a local model with Ollama (https://ollama.com), then `ollama serve`\n" + + "or pass --provider and --model explicitly") + +// resolveTryProvider picks the fastest available model credential with no +// prompts when one exists, and offers a minimal picker only when nothing is. +// Ordering: explicit flags -> env key -> saved OpenAI OAuth -> local Ollama -> +// interactive picker (TTY) -> actionable error (no TTY). It does not add a new +// credential store; it layers an ordering policy over the runner's existing +// resolution. +func resolveTryProvider(ctx context.Context, flags tryFlags, in io.Reader, out io.Writer, interactive, color bool) (tryResolution, error) { + // 1. Explicit flags win. + if flags.provider != "" { + model := modelOrDefault(flags.provider, flags.model) + return tryResolution{ + Provider: flags.provider, + Model: model, + Label: fmt.Sprintf("Using %s (%s).", flags.provider, model), + }, nil + } + + // 2. Env key, preferring Anthropic, then OpenAI, then Gemini. + for _, e := range []struct{ env, provider string }{ + {"ANTHROPIC_API_KEY", "anthropic"}, + {"OPENAI_API_KEY", "openai"}, + {"GEMINI_API_KEY", "gemini"}, + } { + if os.Getenv(e.env) != "" { + return tryResolution{ + Provider: e.provider, + Model: modelOrDefault(e.provider, flags.model), + Label: fmt.Sprintf("Using %s from env.", e.env), + }, nil + } + } + + // 3. Saved OpenAI OAuth session (Responses endpoint). + if tok, err := oauth.LoadCredentials("openai"); err == nil && tok != nil && tok.RefreshToken != "" { + return tryResolution{ + Provider: "openai", + Model: modelOrDefault("openai", flags.model), + Label: "Using OpenAI (signed in).", + }, nil + } + + // 4. Local Ollama daemon. + if ollamaReachable(ctx) { + model := modelOrDefault("ollama", flags.model) + return tryResolution{ + Provider: "ollama", + Model: model, + Label: fmt.Sprintf("Using local Ollama (%s). First response may be slow if the model must download.", model), + }, nil + } + + // 5/6. Nothing available. + if !interactive { + return tryResolution{}, errNoTryCredential + } + return tryPicker(flags, in, out, color) +} + +// modelOrDefault returns the explicit model if set, else the provider default. +func modelOrDefault(provider, model string) string { + if model != "" { + return model + } + return defaultModelNameForProvider(provider) +} + +// ollamaReachable reports whether a local Ollama daemon answers on its default +// port (or OLLAMA_HOST/OLLAMA_BASE_URL host:port). A short dial keeps startup +// snappy when Ollama is absent. +func ollamaReachable(ctx context.Context) bool { + addr := "127.0.0.1:11434" + if h := os.Getenv("OLLAMA_HOST"); h != "" { + addr = strings.TrimPrefix(strings.TrimPrefix(h, "http://"), "https://") + } + dctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond) + defer cancel() + conn, err := (&net.Dialer{}).DialContext(dctx, "tcp", addr) + if err != nil { + return false + } + _ = conn.Close() + return true +} + +// tryPicker is the fallback shown only when nothing is auto-detected and a TTY +// is present: sign in with OpenAI, paste a key, or use local Ollama. +func tryPicker(flags tryFlags, in io.Reader, out io.Writer, color bool) (tryResolution, error) { + accent := tryAccent(color) + dim := tryDim(color) + num := func(n string) string { return accent("[" + n + "]") } + + _, _ = fmt.Fprintf(out, "\n No model credential found. How would you like to connect?\n\n") + _, _ = fmt.Fprintf(out, " %s Sign in with OpenAI %s\n", num("1"), dim("(recommended)")) + _, _ = fmt.Fprintf(out, " %s Paste an API key\n", num("2")) + _, _ = fmt.Fprintf(out, " %s Use local Ollama\n", num("3")) + _, _ = fmt.Fprintf(out, "\n %s ", accent("❯")) + + choice, _ := bufio.NewReader(in).ReadString('\n') + switch strings.TrimSpace(choice) { + case "1", "": + if _, err := runOAuthFlow("openai"); err != nil { + return tryResolution{}, fmt.Errorf("OpenAI sign-in: %w", err) + } + return tryResolution{ + Provider: "openai", + Model: modelOrDefault("openai", flags.model), + Label: "Using OpenAI (signed in).", + }, nil + case "2": + return pasteKeyResolution(flags, out, color) + case "3": + model := modelOrDefault("ollama", flags.model) + return tryResolution{ + Provider: "ollama", + Model: model, + Label: fmt.Sprintf("Using local Ollama (%s).", model), + }, nil + default: + return tryResolution{}, fmt.Errorf("unrecognized choice %q", strings.TrimSpace(choice)) + } +} + +// pasteKeyResolution prompts for a provider and a masked API key. The key is +// held only in memory for this session (via res.EnvOverrides consumed by +// NewLocalSession) and is never written to disk — not even under --keep, whose +// scaffold env is always empty. A kept agent therefore has no credential on +// disk; a later `forge serve` there needs the env var set by hand. +func pasteKeyResolution(flags tryFlags, out io.Writer, color bool) (tryResolution, error) { + // Colored shortname chips: [o] OpenAI [a] Anthropic [g] Gemini. + chip := func(short, name, hex string) string { + if !color { + return fmt.Sprintf("[%s] %s", short, name) + } + st := lipgloss.NewStyle().Foreground(lipgloss.Color(hex)).Bold(true) + return st.Render("["+short+"]") + " " + lipgloss.NewStyle().Foreground(lipgloss.Color(hex)).Render(name) + } + _, _ = fmt.Fprintf(out, " Provider: %s %s %s\n", + chip("o", "OpenAI", "#10a37f"), // OpenAI green + chip("a", "Anthropic", "#d97757"), // Anthropic clay + chip("g", "Gemini", "#4285f4"), // Google blue + ) + _, _ = fmt.Fprintf(out, " %s ", tryAccent(color)("❯")) + + prov, _ := bufio.NewReader(os.Stdin).ReadString('\n') + provider, ok := providerFromInput(prov) + if !ok { + return tryResolution{}, fmt.Errorf("unrecognized provider %q; use o/a/g or openai/anthropic/gemini", strings.TrimSpace(prov)) + } + keyEnv := providerKeyEnv[provider] + _, _ = fmt.Fprint(out, " API key: ") + raw, err := readMaskedLine(out) + if err != nil { + return tryResolution{}, fmt.Errorf("reading key: %w", err) + } + key := strings.TrimSpace(raw) + if key == "" { + return tryResolution{}, fmt.Errorf("empty API key") + } + return tryResolution{ + Provider: provider, + Model: modelOrDefault(provider, flags.model), + Label: fmt.Sprintf("Using %s (pasted key).", provider), + EnvOverrides: map[string]string{keyEnv: key}, + }, nil +} + +// readMaskedLine reads a secret from stdin, echoing a • per character so a +// paste is visibly registered (a plain ReadPassword shows nothing, making it +// hard to tell whether anything was pasted). Handles backspace and Ctrl-C; +// falls back to a silent read when the terminal can't be put in raw mode +// (non-TTY / piped input). +func readMaskedLine(out io.Writer) (string, error) { + fd := int(os.Stdin.Fd()) + oldState, err := term.MakeRaw(fd) + if err != nil { + raw, rerr := term.ReadPassword(fd) + _, _ = fmt.Fprintln(out) + return string(raw), rerr + } + defer func() { _ = term.Restore(fd, oldState) }() + + var buf []byte + b := make([]byte, 1) + for { + n, rerr := os.Stdin.Read(b) + if n > 0 { + switch c := b[0]; c { + case '\r', '\n': + _, _ = fmt.Fprint(out, "\r\n") + return string(buf), nil + case 3: // Ctrl-C + _, _ = fmt.Fprint(out, "\r\n") + return "", fmt.Errorf("cancelled") + case 127, 8: // backspace / delete + if len(buf) > 0 { + buf = buf[:len(buf)-1] + _, _ = fmt.Fprint(out, "\b \b") + } + default: + if c >= 32 { // printable + buf = append(buf, c) + _, _ = fmt.Fprint(out, "•") + } + } + } + if rerr != nil { + _, _ = fmt.Fprint(out, "\r\n") + if len(buf) > 0 { + return string(buf), nil + } + return "", rerr + } + } +} + +// providerFromInput maps a paste-key provider answer to its canonical name, +// accepting the shortname (o/a/g) or the full name (openai/anthropic/gemini). +func providerFromInput(s string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "o", "openai": + return "openai", true + case "a", "anthropic": + return "anthropic", true + case "g", "gemini": + return "gemini", true + } + return "", false +} + +// providerKeyEnv maps a provider to its API-key environment variable. +var providerKeyEnv = map[string]string{ + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "gemini": "GEMINI_API_KEY", +} diff --git a/forge-cli/cmd/try_test.go b/forge-cli/cmd/try_test.go new file mode 100644 index 00000000..c296f70e --- /dev/null +++ b/forge-cli/cmd/try_test.go @@ -0,0 +1,190 @@ +package cmd + +import ( + "context" + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/initializ/forge/forge-cli/config" +) + +// TestQuickstartPreset_NoPlaceholderCredential pins that the preset scaffold +// writes NO provider-key placeholder. A placeholder in .env would be loaded by +// NewLocalSession, shadow the real runtime credential (OAuth / env / paste), +// and get sent to the provider as an invalid key. +func TestQuickstartPreset_NoPlaceholderCredential(t *testing.T) { + dir := filepath.Join(t.TempDir(), "quickstart") + opts := quickstartPreset("openai", "gpt-5.4") + opts.OutputDir = dir + opts.Force = true + if err := scaffold(opts); err != nil { + t.Fatalf("scaffold: %v", err) + } + env, _ := os.ReadFile(filepath.Join(dir, ".env")) + if strings.Contains(string(env), "your-api-key-here") { + t.Errorf(".env carries a placeholder credential that would shadow runtime resolution:\n%s", env) + } + if strings.Contains(string(env), "OPENAI_API_KEY") { + t.Errorf(".env must not scaffold OPENAI_API_KEY for the preset:\n%s", env) + } +} + +// TestQuickstartPreset_ScaffoldsValidConfig runs the demo preset through the +// real scaffold path and asserts the generated forge.yaml is valid and keyless. +func TestQuickstartPreset_ScaffoldsValidConfig(t *testing.T) { + dir := filepath.Join(t.TempDir(), "quickstart") + opts := quickstartPreset("ollama", "llama3.2") + opts.OutputDir = dir + opts.Force = true + + if err := scaffold(opts); err != nil { + t.Fatalf("scaffold: %v", err) + } + + cfg, err := config.LoadForgeConfig(filepath.Join(dir, "forge.yaml")) + if err != nil { + t.Fatalf("LoadForgeConfig: %v", err) + } + if cfg.AgentID != "quickstart" { + t.Errorf("agent_id = %q, want quickstart", cfg.AgentID) + } + if cfg.Model.Provider != "ollama" || cfg.Model.Name != "llama3.2" { + t.Errorf("model = %q/%q, want ollama/llama3.2", cfg.Model.Provider, cfg.Model.Name) + } + if len(cfg.BuiltinTools) != 3 { + t.Errorf("builtin_tools = %v, want 3 (http_request, datetime_now, math_calculate)", cfg.BuiltinTools) + } + // Egress is allowlist and keyless (wttr.in), never the old key-gated hosts. + if cfg.Egress.Mode != "allowlist" { + t.Errorf("egress mode = %q, want allowlist", cfg.Egress.Mode) + } + var hasWttr bool + for _, d := range cfg.Egress.AllowedDomains { + if d == "wttr.in" { + hasWttr = true + } + if d == "api.openweathermap.org" || d == "api.weatherapi.com" { + t.Errorf("egress lists key-gated host %q; the demo must stay keyless", d) + } + } + if !hasWttr { + t.Errorf("egress = %v, want it to include wttr.in", cfg.Egress.AllowedDomains) + } +} + +func TestResolveTryProvider_FlagsWin(t *testing.T) { + res, err := resolveTryProvider(context.Background(), + tryFlags{provider: "anthropic", model: "claude-x"}, nil, io.Discard, false, false) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if res.Provider != "anthropic" || res.Model != "claude-x" { + t.Errorf("got %q/%q, want anthropic/claude-x", res.Provider, res.Model) + } +} + +func TestResolveTryProvider_EnvPrecedence(t *testing.T) { + // Anthropic is preferred over OpenAI when both are set. + t.Setenv("ANTHROPIC_API_KEY", "sk-a") + t.Setenv("OPENAI_API_KEY", "sk-o") + res, err := resolveTryProvider(context.Background(), tryFlags{}, nil, io.Discard, false, false) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if res.Provider != "anthropic" { + t.Errorf("provider = %q, want anthropic (preferred over openai)", res.Provider) + } +} + +func TestResolveTryProvider_Ollama(t *testing.T) { + isolateCreds(t) + // A live listener stands in for the Ollama daemon. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { _ = ln.Close() }() + t.Setenv("OLLAMA_HOST", ln.Addr().String()) + + res, err := resolveTryProvider(context.Background(), tryFlags{}, nil, io.Discard, false, false) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if res.Provider != "ollama" { + t.Errorf("provider = %q, want ollama", res.Provider) + } +} + +func TestResolveTryProvider_NoCredsNonInteractive(t *testing.T) { + isolateCreds(t) + t.Setenv("OLLAMA_HOST", "127.0.0.1:1") // unreachable + + _, err := resolveTryProvider(context.Background(), tryFlags{}, nil, io.Discard, false, false) + if err == nil { + t.Fatal("want an error when no credential is available and no TTY") + } +} + +func TestProviderFromInput(t *testing.T) { + ok := map[string]string{ + "o": "openai", "openai": "openai", "OpenAI": "openai", + "a": "anthropic", "anthropic": "anthropic", + "g": "gemini", " Gemini ": "gemini", + } + for in, want := range ok { + if got, valid := providerFromInput(in); !valid || got != want { + t.Errorf("providerFromInput(%q) = %q,%v; want %q,true", in, got, valid, want) + } + } + for _, bad := range []string{"", "x", "gpt", "claude"} { + if _, valid := providerFromInput(bad); valid { + t.Errorf("providerFromInput(%q) should be invalid", bad) + } + } +} + +func TestTryWorkspace(t *testing.T) { + t.Run("keep targets cwd, no cleanup", func(t *testing.T) { + dir, cleanup, err := tryWorkspace(true) + if err != nil { + t.Fatalf("tryWorkspace: %v", err) + } + if dir != filepath.Join(".", "forge-quickstart") { + t.Errorf("dir = %q, want ./forge-quickstart", dir) + } + if cleanup != nil { + t.Error("keep should have no cleanup func") + } + }) + t.Run("ephemeral temp dir is removed", func(t *testing.T) { + dir, cleanup, err := tryWorkspace(false) + if err != nil { + t.Fatalf("tryWorkspace: %v", err) + } + base := filepath.Dir(dir) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if _, err := os.Stat(base); err != nil { + t.Fatalf("temp base should exist: %v", err) + } + cleanup() + if _, err := os.Stat(base); !os.IsNotExist(err) { + t.Errorf("cleanup should remove the temp base, stat err = %v", err) + } + }) +} + +// isolateCreds points HOME at a throwaway dir so a developer's real OpenAI +// OAuth token can't leak into the resolution-order tests. +func isolateCreds(t *testing.T) { + t.Helper() + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("GEMINI_API_KEY", "") + t.Setenv("HOME", t.TempDir()) +} diff --git a/forge-cli/internal/tryview/renderer.go b/forge-cli/internal/tryview/renderer.go new file mode 100644 index 00000000..896902d8 --- /dev/null +++ b/forge-cli/internal/tryview/renderer.go @@ -0,0 +1,229 @@ +// Package tryview renders the `forge try` agent's audit stream as inline, +// human-readable "loop" lines — tool calls, egress checks, guardrail blocks — +// so the first-run experience shows the agent working, not just its reply. +// +// The renderer implements the coreruntime audit Sink contract, so it attaches +// alongside (or instead of) the NDJSON sink. It reads existing audit events +// only; it never derives fields that aren't on the event. Styling degrades to +// plain text when color is unavailable. +package tryview + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Renderer maps audit events to inline loop lines. Zero value is not usable; +// construct with New. +type Renderer struct { + out io.Writer + style styles + quiet bool // hide every loop line and the summary + audit bool // echo full NDJSON instead of the mapped lines + turn turnSummary +} + +// New builds a Renderer writing to out. quiet hides the loop; audit echoes the +// full NDJSON stream; color enables the orange/grey styling (pass false for a +// non-TTY or NO_COLOR). +func New(out io.Writer, quiet, audit, color bool) *Renderer { + return &Renderer{out: out, style: newStyles(color), quiet: quiet, audit: audit} +} + +// turnSummary accumulates the salient facts of one turn for the compact audit +// line printed after the agent reply. +type turnSummary struct { + Tools []string `json:"tools,omitempty"` + Egress []string `json:"egress,omitempty"` // "domain:allow" / "domain:block" +} + +// auditLine is the subset of the audit event the renderer reads. +type auditLine struct { + Event string `json:"event"` + Fields map[string]any `json:"fields"` +} + +// Write implements the audit Sink interface. eventBytes is one NDJSON line +// (newline included). +func (r *Renderer) Write(_ context.Context, eventBytes []byte) error { + if r.audit { + _, _ = r.out.Write(eventBytes) + return nil + } + if r.quiet { + return nil + } + var e auditLine + if err := json.Unmarshal(eventBytes, &e); err != nil { + return nil // never fail the emit path over a render hiccup + } + if line := r.lineFor(&e); line != "" { + _, _ = fmt.Fprintln(r.out, line) + } + return nil +} + +// Close implements the audit Sink interface. +func (r *Renderer) Close(context.Context) error { return nil } + +// Name implements the audit Sink interface. +func (r *Renderer) Name() string { return "tryview" } + +// Stats implements the audit Sink interface. +func (r *Renderer) Stats() map[string]int64 { return nil } + +// FlushSummary prints the one-line compact audit summary for the turn (dim), +// then resets it. Call it after printing the agent reply. No-op under --quiet / +// --audit or when the turn touched no tools or egress. +func (r *Renderer) FlushSummary() { + defer func() { r.turn = turnSummary{} }() + if r.quiet || r.audit { + return + } + if len(r.turn.Tools) == 0 && len(r.turn.Egress) == 0 { + return + } + b, _ := json.Marshal(r.turn) + _, _ = fmt.Fprintln(r.out, " "+r.style.dim("audit "+string(b))) +} + +// lineFor maps one audit event to its rendered line, or "" to render nothing. +func (r *Renderer) lineFor(e *auditLine) string { + switch e.Event { + case "tool_exec": + return r.toolLine(e) + case "egress_allowed": + domain := fieldStr(e.Fields, "domain") + r.turn.Egress = append(r.turn.Egress, domain+":allow") + return r.prefix("egress") + " " + domain + " " + r.style.ok("✓ allowed") + case "egress_blocked": + domain := fieldStr(e.Fields, "domain") + r.turn.Egress = append(r.turn.Egress, domain+":block") + return r.prefix("egress") + " " + domain + " " + r.style.bad("✗ blocked") + case "guardrail_check": + if !guardBlocked(e.Fields) { + return "" + } + name := firstStr(e.Fields, "name", "guardrail", "rule") + return r.prefix("guard") + " " + name + " " + r.style.bad("✗ blocked") + } + return "" +} + +// toolLine renders a tool_exec start (the call) or end (the result / error). +func (r *Renderer) toolLine(e *auditLine) string { + tool := fieldStr(e.Fields, "tool") + switch fieldStr(e.Fields, "phase") { + case "start": + if tool != "" { + r.turn.Tools = append(r.turn.Tools, tool) + } + return r.prefix("tool") + " " + tool + "(" + argPreview(e.Fields["args"]) + ")" + case "end": + if msg := fieldStr(e.Fields, "error"); msg != "" { + return " " + r.style.label("◂") + " " + r.style.bad("error: "+truncate(oneLine(msg), 80)) + } + if res := fieldStr(e.Fields, "result"); res != "" { + return " " + r.style.label("◂") + " " + truncate(oneLine(res), 80) + } + } + return "" +} + +// prefix builds the styled " ▸