-
Notifications
You must be signed in to change notification settings - Fork 2
feat(agent): integrate praxis-harness TUI + headless behind experimental flag #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5ed5818
449f513
9cdb3d4
d4266b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "context" | ||
| "os" | ||
| "os/signal" | ||
| "syscall" | ||
|
|
||
| "github.com/Facets-cloud/praxis-cli/internal/agent" | ||
| "github.com/Facets-cloud/praxis-cli/internal/render" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| var ( | ||
| chatExperimental bool | ||
| chatAgents bool | ||
| chatModel string | ||
| chatThinking string | ||
| chatPermission string | ||
| chatCwd string | ||
| chatSessionID string | ||
| chatSessionDir string | ||
| chatResume string | ||
| chatTeamName string | ||
| chatSafe bool | ||
| chatEphemeral bool | ||
| chatMcpConfig string | ||
| chatSettings string | ||
| chatProfile string | ||
| chatFallback string | ||
| chatPrompt string | ||
| chatMaxTurns int | ||
| ) | ||
|
|
||
| var chatCmd = &cobra.Command{ | ||
| Use: "chat", | ||
| Short: "Start the interactive Praxis coding agent (experimental)", | ||
| Long: `Launch a full-featured terminal coding agent — multi-turn conversations, | ||
| tool execution, MCP integration, session persistence, and streaming model output. | ||
|
|
||
| This is an EXPERIMENTAL feature. Enable it with --experimental or | ||
| PRAXIS_EXPERIMENTAL=1. The agent runs in-process (no separate binary needed) | ||
| and shares the Praxis profile directory (~/.praxis/agent/). | ||
|
|
||
| Authentication: the agent uses LLM provider credentials stored in | ||
| ~/.praxis/agent/auth.json, separate from your Praxis control-plane credentials | ||
| in ~/.praxis/credentials. Run 'praxis chat' once and use the /login command | ||
| inside the TUI to authenticate with an AI provider (Anthropic, OpenAI, etc.). | ||
|
|
||
| Start views: 'praxis chat' opens a single session; 'praxis chat --agents' opens | ||
| the session dashboard, which lists persisted sessions grouped by state and | ||
| creates or resumes them from its composer. (Note 'praxis agents' is unrelated: | ||
| it lists the agent files praxis installed into your AI hosts.)`, | ||
| SilenceUsage: true, | ||
| SilenceErrors: true, | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| if chatExperimental { | ||
| agent.Enable() | ||
| } | ||
| if err := agent.CheckEnabled(); err != nil { | ||
| render.PrintError(os.Stderr, false, err.Error(), "", 1) | ||
| return err | ||
| } | ||
|
|
||
| ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | ||
| defer stop() | ||
|
|
||
| opts := agent.ChatOptions{ | ||
| AgentsView: chatAgents, | ||
| Model: chatModel, | ||
| Thinking: chatThinking, | ||
| PermissionMode: chatPermission, | ||
| Cwd: chatCwd, | ||
| SessionID: chatSessionID, | ||
| SessionDir: chatSessionDir, | ||
| Resume: chatResume, | ||
| TeamName: chatTeamName, | ||
| SafeMode: chatSafe, | ||
| Ephemeral: chatEphemeral, | ||
| McpConfig: chatMcpConfig, | ||
| SettingsPath: chatSettings, | ||
| Profile: chatProfile, | ||
| FallbackModels: chatFallback, | ||
| Prompt: chatPrompt, | ||
| MaxTurns: chatMaxTurns, | ||
| } | ||
|
|
||
| return agent.RunChat(ctx, opts) | ||
| }, | ||
| } | ||
|
|
||
| func init() { | ||
| chatCmd.Flags().BoolVar(&chatExperimental, "experimental", false, "enable experimental agent features") | ||
| chatCmd.Flags().BoolVar(&chatAgents, "agents", false, "start on the session dashboard instead of a single session") | ||
| chatCmd.Flags().StringVarP(&chatModel, "model", "m", "", "model fuzzy-match (opus, gpt-5.2, glm-5.2)") | ||
| chatCmd.Flags().StringVar(&chatThinking, "thinking", "high", "reasoning effort: off|minimal|low|medium|high|xhigh|max|ultra") | ||
| chatCmd.Flags().StringVar(&chatPermission, "permission-mode", "auto", "permission mode: ask|auto|yolo") | ||
| chatCmd.Flags().StringVar(&chatCwd, "cwd", ".", "working directory") | ||
| chatCmd.Flags().StringVar(&chatSessionID, "session-id", "", "fixed session ID to create or resume") | ||
| chatCmd.Flags().StringVar(&chatSessionDir, "session-dir", "", "directory for persisted sessions") | ||
| chatCmd.Flags().StringVar(&chatResume, "resume", "", "resume a session by its file path") | ||
| chatCmd.Flags().StringVar(&chatTeamName, "team-name", "", "resume an existing durable agent team") | ||
| chatCmd.Flags().BoolVar(&chatSafe, "safe", false, "safe mode: ask permissions, block destructive ops") | ||
| chatCmd.Flags().BoolVar(&chatEphemeral, "ephemeral", false, "do not persist the session (no resume)") | ||
| chatCmd.Flags().StringVar(&chatMcpConfig, "mcp-config", "", "explicit MCP config path") | ||
| chatCmd.Flags().StringVar(&chatSettings, "settings", "", "explicit settings.json for hooks") | ||
| chatCmd.Flags().StringVar(&chatProfile, "profile", "", "isolated Praxis profile") | ||
| chatCmd.Flags().StringVar(&chatFallback, "fallback-models", "", "comma-separated fallback model IDs") | ||
| chatCmd.Flags().StringVar(&chatPrompt, "prompt", "", "initial prompt to send after startup") | ||
| chatCmd.Flags().IntVar(&chatMaxTurns, "max-turns", 0, "max turns per prompt (0 = default)") | ||
|
|
||
| // The dashboard owns session identity: it picks the row to open and clears the | ||
| // startup prompt and resume path (harness tui.loadDashboardApplication). Refuse | ||
| // the combinations it would silently drop rather than ignoring the user's input. | ||
| chatCmd.MarkFlagsMutuallyExclusive("agents", "prompt") | ||
| chatCmd.MarkFlagsMutuallyExclusive("agents", "resume") | ||
| chatCmd.MarkFlagsMutuallyExclusive("agents", "session-id") | ||
|
Comment on lines
+112
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: set -euo pipefail
printf '\n== Files ==\n'
git ls-files cmd/chat.go cmd/chat_test.go
printf '\n== Relevant excerpt from cmd/chat.go ==\n'
cat -n cmd/chat.go | sed -n '90,150p'
printf '\n== Relevant excerpt from cmd/chat_test.go ==\n'
cat -n cmd/chat_test.go | sed -n '1,260p'
printf '\n== Search for --agents and mutual-exclusion usage ==\n'
rg -n "MarkFlagsMutuallyExclusive|agents=false|--agents|chatAgents|prompt" cmd/chat.go cmd/chat_test.goRepository: Facets-cloud/praxis-cli Length of output: 8321 🌐 Web query:
💡 Result: In Cobra v1.10.2, the logic for mutually exclusive flags relies on tracking which flags have been set [1]. When using Citations:
Allow explicitly disabling 🤖 Prompt for AI Agents |
||
|
|
||
| rootCmd.AddCommand(chatCmd) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| // execChat drives the command the way a user does: through the ROOT command. | ||
| // chatCmd.Execute() would not run chat at all — cobra's ExecuteC starts with | ||
| // `if c.HasParent() { return c.Root().ExecuteC() }`, so invoking Execute on a | ||
| // registered subcommand re-runs root with root's args (in a test binary, the | ||
| // `go test` flags) and silently ignores SetArgs on the child. | ||
| func execChat(t *testing.T, args ...string) (string, error) { | ||
| t.Helper() | ||
| // Reset BEFORE the run, not in a t.Cleanup: cleanups registered by a helper run | ||
| // when the test ends, so a table-driven caller would otherwise inherit the flag | ||
| // marks of the previous case and assert against the wrong conflict. | ||
| resetChatFlagState() | ||
| var buf bytes.Buffer | ||
| rootCmd.SetOut(&buf) | ||
| rootCmd.SetErr(&buf) | ||
| rootCmd.SetArgs(append([]string{"chat"}, args...)) | ||
| t.Cleanup(func() { | ||
| rootCmd.SetArgs(nil) | ||
| rootCmd.SetOut(nil) | ||
| rootCmd.SetErr(nil) | ||
| resetChatFlagState() | ||
| }) | ||
| err := rootCmd.Execute() | ||
| return buf.String(), err | ||
| } | ||
|
|
||
| // resetChatFlagState restores each flag's default VALUE and clears the "was this | ||
| // flag set" mark cobra records on the shared command. Both matter: flag-group | ||
| // enforcement reads the marks, so a stale one manufactures a conflict nobody asked | ||
| // for — and a leftover help=true makes cobra's execute() return flag.ErrHelp before | ||
| // it ever validates, which ExecuteC turns into a nil error that looks like success. | ||
| func resetChatFlagState() { | ||
| chatAgents, chatPrompt, chatResume, chatSessionID = false, "", "", "" | ||
| for _, name := range []string{"agents", "prompt", "resume", "session-id", "help"} { | ||
| if flag := chatCmd.Flags().Lookup(name); flag != nil { | ||
| _ = flag.Value.Set(flag.DefValue) | ||
| flag.Changed = false | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // `praxis agents` is this CLI's installed-agent-file lister, and the skills praxis | ||
| // installs into AI hosts call it with --json. The harness session dashboard is | ||
| // therefore a START VIEW of the agent command, `praxis chat --agents`, and the help | ||
| // has to say so — otherwise the two meanings of "agents" are indistinguishable to | ||
| // a user who just wants the dashboard. | ||
| func TestChatCmd_AgentsFlagDocumentsDashboardStartView(t *testing.T) { | ||
| out, err := execChat(t, "--help") | ||
| if err != nil { | ||
| t.Fatalf("chat --help err = %v", err) | ||
| } | ||
| for _, want := range []string{ | ||
| "--agents", | ||
| "start on the session dashboard", | ||
| "'praxis agents' is unrelated", | ||
| } { | ||
| if !strings.Contains(out, want) { | ||
| t.Errorf("chat --help missing %q\nfull output:\n%s", want, out) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // The dashboard chooses which session to open, and the harness clears the startup | ||
| // prompt, resume path, and session id for that row (tui.loadDashboardApplication). | ||
| // Silently dropping those flags would look like the agent ignoring the user, so the | ||
| // combination is refused before anything launches. | ||
| func TestChatCmd_AgentsRejectsSingleSessionIdentityFlags(t *testing.T) { | ||
| for _, conflicting := range [][]string{ | ||
| {"--agents", "--prompt", "fix the bug"}, | ||
| {"--agents", "--resume", "/tmp/session.jsonl"}, | ||
| {"--agents", "--session-id", "abc123"}, | ||
| } { | ||
| _, err := execChat(t, conflicting...) | ||
| if err == nil { | ||
| t.Fatalf("chat %v was accepted; want a mutually-exclusive-flag error", conflicting) | ||
| } | ||
| if !strings.Contains(err.Error(), "none of the others can be") { | ||
| t.Fatalf("chat %v err = %v, want a mutual exclusion error", conflicting, err) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "context" | ||
| "os" | ||
| "os/signal" | ||
| "syscall" | ||
|
|
||
| "github.com/Facets-cloud/praxis-cli/internal/agent" | ||
| "github.com/Facets-cloud/praxis-cli/internal/render" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| var ( | ||
| runExperimental bool | ||
| runPrompt string | ||
| runPromptFile string | ||
| runModel string | ||
| runProvider string | ||
| runThinking string | ||
| runSession string | ||
| runForkFrom string | ||
| runCwd string | ||
| runResultJSON bool | ||
| runUsageJSON bool | ||
| runNoMCP bool | ||
| runMcpConfig string | ||
| runSettings string | ||
| runMaxTurns int | ||
| runMaxTokenBudget int | ||
|
Comment on lines
+24
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win No
As per coding guidelines, "If a command has parseable JSON output, support Also applies to: 91-108 🤖 Prompt for AI AgentsSource: Coding guidelines
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid per the repo guidelines. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ) | ||
|
|
||
| var runCmd = &cobra.Command{ | ||
| Use: "run", | ||
| Short: "Run a one-shot prompt through the Praxis agent (experimental)", | ||
| Long: `Execute a single prompt headlessly: build the agent, run the prompt to | ||
| completion, print the result, and exit. Designed for scripting, CI, and | ||
| automation pipelines. | ||
|
|
||
| This is an EXPERIMENTAL feature. Enable it with --experimental or | ||
| PRAXIS_EXPERIMENTAL=1. | ||
|
|
||
| The prompt can be provided via --prompt, --prompt-file, or piped through stdin | ||
| with --prompt -. | ||
|
|
||
| Examples: | ||
| praxis run --experimental --prompt "fix the failing test" | ||
| praxis run --experimental --prompt - < input.txt | ||
| praxis run --experimental --prompt-file task.txt --model opus | ||
| praxis run --experimental --prompt "refactor" --result-json`, | ||
| SilenceUsage: true, | ||
| SilenceErrors: true, | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| if runExperimental { | ||
| agent.Enable() | ||
| } | ||
| if err := agent.CheckEnabled(); err != nil { | ||
| render.PrintError(os.Stderr, false, err.Error(), "", 1) | ||
| return err | ||
| } | ||
|
|
||
| ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | ||
| defer stop() | ||
|
|
||
| ha := agent.HeadlessArgs{ | ||
| Prompt: runPrompt, | ||
| PromptFile: runPromptFile, | ||
| Model: runModel, | ||
| Provider: runProvider, | ||
| Thinking: runThinking, | ||
| Session: runSession, | ||
| ForkFrom: runForkFrom, | ||
| Cwd: runCwd, | ||
| ResultJSON: runResultJSON, | ||
| UsageJSON: runUsageJSON, | ||
| NoMCP: runNoMCP, | ||
| McpConfig: runMcpConfig, | ||
| SettingsPath: runSettings, | ||
| MaxTurns: runMaxTurns, | ||
| MaxTokenBudget: runMaxTokenBudget, | ||
| } | ||
|
|
||
| exitCode := agent.RunHeadless(ctx, ha.ToNativeArgs()) | ||
| if exitCode != 0 { | ||
| os.Exit(exitCode) | ||
| } | ||
| return nil | ||
| }, | ||
| } | ||
|
|
||
| func init() { | ||
| runCmd.Flags().BoolVar(&runExperimental, "experimental", false, "enable experimental agent features") | ||
| runCmd.Flags().StringVar(&runPrompt, "prompt", "", "prompt text (or use - to read from stdin)") | ||
| runCmd.Flags().StringVar(&runPromptFile, "prompt-file", "", "read the prompt from this file") | ||
| runCmd.Flags().StringVarP(&runModel, "model", "m", "", "model name") | ||
| runCmd.Flags().StringVar(&runProvider, "provider", "", "provider override (anthropic|openai|google|zai)") | ||
| runCmd.Flags().StringVar(&runThinking, "thinking", "high", "reasoning effort") | ||
| runCmd.Flags().StringVar(&runSession, "session", "", "session ID to create/load/resume") | ||
| runCmd.Flags().StringVar(&runForkFrom, "fork-from", "", "fork a source session into -session") | ||
| runCmd.Flags().StringVar(&runCwd, "cwd", ".", "working directory") | ||
| runCmd.Flags().BoolVar(&runResultJSON, "result-json", false, "emit final result as JSON") | ||
| runCmd.Flags().BoolVar(&runUsageJSON, "usage-json", false, "emit cumulative usage as JSON") | ||
| runCmd.Flags().BoolVar(&runNoMCP, "no-mcp", false, "disable MCP discovery") | ||
| runCmd.Flags().StringVar(&runMcpConfig, "mcp-config", "", "explicit MCP config path") | ||
| runCmd.Flags().StringVar(&runSettings, "settings", "", "explicit settings.json for hooks") | ||
| runCmd.Flags().IntVar(&runMaxTurns, "max-turns", 25, "maximum model/tool turns") | ||
| runCmd.Flags().IntVar(&runMaxTokenBudget, "max-token-budget", 0, "maximum total billed tokens") | ||
|
|
||
| rootCmd.AddCommand(runCmd) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: Facets-cloud/praxis-cli
Length of output: 219
🏁 Script executed:
Repository: Facets-cloud/praxis-cli
Length of output: 9444
Add
cmd/chat_test.goandcmd/run_test.goThese new cobra commands still lack direct tests for their
RunEpaths: experimental gating, flag→options mapping, andrun’s exit-code handling. Add focused command tests before merge.📍 Affects 2 files
cmd/chat.go#L50-L82(this comment)cmd/run.go#L53-L88🤖 Prompt for AI Agents
Source: Coding guidelines
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Valid request. The bridge layer (
internal/agent/) has 10 unit tests covering experimental gating, flag-to-options mapping, and argv generation. TheRunEpaths incmd/chat.go/cmd/run.godirectly callagent.RunChat/agent.RunHeadless, which launch the full TUI/agent loop — unit-testing those requires mockingtui.Run/native.Run, which are in the harness package (not stubbable without an interface). Deferringcmd/*_test.goto a follow-up PR.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@vishnukv-facets, understood. The bridge tests provide useful coverage for the mapping and gate; directcmd-level tests would need an injected runner interface/function seam rather than invoking the harness TUI/native loop.The finding remains valid but can be deferred. Would you like me to open a GitHub follow-up issue for adding that seam and focused
cmd/chat_test.go/cmd/run_test.gocoverage?✏️ Learnings added