Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: actions/setup-go@v6
with:
go-version: "1.24"
go-version: "1.25"
cache: true

# praxis-harness is a private repo in the same org. The default
# GITHUB_TOKEN can't read cross-repo, so CI_GITHUB_TOKEN (a PAT with
# read access to Facets-cloud repos) authenticates module fetches.
- name: Configure private module access
env:
CI_GITHUB_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }}
run: |
git config --global url."https://x-access-token:${CI_GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
go env -w GOPRIVATE="github.com/Facets-cloud/*"
go env -w GONOSUMDB="github.com/Facets-cloud/*"

- name: gofmt
run: |
unformatted=$(gofmt -l .)
Expand All @@ -42,8 +54,17 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: "1.24"
go-version: "1.25"
cache: true

- name: Configure private module access
env:
CI_GITHUB_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }}
run: |
git config --global url."https://x-access-token:${CI_GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
go env -w GOPRIVATE="github.com/Facets-cloud/*"
go env -w GONOSUMDB="github.com/Facets-cloud/*"

- uses: goreleaser/goreleaser-action@v7
with:
version: "~> v2"
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,17 @@ jobs:

- uses: actions/setup-go@v6
with:
go-version: "1.24"
go-version: "1.25"
cache: true

- name: Configure private module access
env:
CI_GITHUB_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }}
run: |
git config --global url."https://x-access-token:${CI_GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
go env -w GOPRIVATE="github.com/Facets-cloud/*"
go env -w GONOSUMDB="github.com/Facets-cloud/*"

- name: Verify HOMEBREW_TAP_TOKEN propagates + can write to homebrew-tap
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
Expand Down
120 changes: 120 additions & 0 deletions cmd/chat.go
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)
},
Comment on lines +56 to +89

@coderabbitai coderabbitai Bot Jul 29, 2026

Copy link
Copy Markdown

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:

#!/bin/bash
# Confirm no test files exist for the new commands
fd -g 'chat_test.go' -g 'run_test.go' cmd

Repository: Facets-cloud/praxis-cli

Length of output: 219


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Top-level cmd files:\n'
git ls-files cmd | sort

printf '\nTest files under cmd:\n'
git ls-files 'cmd/*_test.go' | sort

printf '\nRelevant command files:\n'
for f in cmd/chat.go cmd/run.go; do
  if [ -f "$f" ]; then
    printf '\n== %s ==\n' "$f"
    wc -l "$f"
    sed -n '1,220p' "$f"
  fi
done

Repository: Facets-cloud/praxis-cli

Length of output: 9444


Add cmd/chat_test.go and cmd/run_test.go
These new cobra commands still lack direct tests for their RunE paths: experimental gating, flag→options mapping, and run’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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/chat.go` around lines 50 - 82, Add focused direct tests in
cmd/chat_test.go for the chat command’s RunE, covering experimental gating,
agent.CheckEnabled behavior, and complete flag-to-agent.ChatOptions mapping; add
cmd/run_test.go for run’s RunE, specifically verifying exit-code handling. Cover
both successful and relevant failure paths while isolating command dependencies.

Source: Coding guidelines

Copy link
Copy Markdown
Contributor Author

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. The RunE paths in cmd/chat.go/cmd/run.go directly call agent.RunChat/agent.RunHeadless, which launch the full TUI/agent loop — unit-testing those requires mocking tui.Run/native.Run, which are in the harness package (not stubbable without an interface). Deferring cmd/*_test.go to a follow-up PR.

Copy link
Copy Markdown

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; direct cmd-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.go coverage?


✏️ Learnings added
Learnt from: vishnukv-facets
Repo: Facets-cloud/praxis-cli PR: 65
File: cmd/chat.go:50-82
Timestamp: 2026-07-29T18:16:10.497Z
Learning: In `praxis-cli`, `cmd/chat.go` and `cmd/run.go` directly invoke `internal/agent.RunChat` and `internal/agent.RunHeadless`, which enter the `praxis-harness` TUI/native agent loops. Command-level unit tests require an injectable runner seam because the harness runners are not currently stubbable. `internal/agent/agent_test.go` covers experimental gating, option-to-argument mapping, and argument generation.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

}

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.go

Repository: Facets-cloud/praxis-cli

Length of output: 8321


🌐 Web query:

Cobra v1.10.2 flag_groups.go Flag.Changed mutually exclusive bool flag false conflict

💡 Result:

In Cobra v1.10.2, the logic for mutually exclusive flags relies on tracking which flags have been set [1]. When using cmd.MarkFlagsMutuallyExclusive(...), Cobra internally monitors the Changed status of the flags within the group [1][2]. The Flag.Changed property (from the underlying pflag library) tracks whether a flag was explicitly set during parsing [3][4]. Once a flag's Changed status is true, it remains in that state for the duration of the command's execution [3][4]. If you are encountering conflicts where Cobra incorrectly reports a violation of mutual exclusivity during testing (even when flags are set in separate test runs), it is typically because the pflag.FlagSet is not being reset between tests [5]. Because Changed is true, the validation logic still perceives the flag as being "set" [5]. To resolve this conflict in your tests: 1. Ensure you are resetting the command's flag state between test cases [5]. 2. You can manually reset or re-initialize the FlagSet or the command object for each test to clear the Changed state [5]. 3. For programmatic testing, you can explicitly set the Changed field to false for flags if you need to reuse the same flag set, though re-instantiating the command/flagset is the recommended, cleaner approach [5]. Validation logic in flag_groups.go checks these Changed statuses to enforce exclusivity [1][2]. If more than one flag in a mutually exclusive group has Changed == true, Cobra will trigger an error [1][6][7].

Citations:


Allow explicitly disabling --agents. MarkFlagsMutuallyExclusive treats an explicitly passed --agents=false as set, so praxis chat --agents=false --prompt ... still errors even though it should follow the single-session path. Gate the conflict on chatAgents instead and add a regression test for --agents=false + --prompt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/chat.go` around lines 112 - 117, Replace the MarkFlagsMutuallyExclusive
checks in the chat command with conflicts gated on the chatAgents value, so only
enabled agents mode conflicts with prompt, resume, or session-id; explicitly
passing --agents=false must allow the single-session path. Add a regression test
covering --agents=false together with --prompt.


rootCmd.AddCommand(chatCmd)
}
88 changes: 88 additions & 0 deletions cmd/chat_test.go
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)
}
}
}
110 changes: 110 additions & 0 deletions cmd/run.go
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

@coderabbitai coderabbitai Bot Jul 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

No --json/TTY auto-detection for machine consumers, as required by guidelines.

run is explicitly designed for "scripting, CI, and automation pipelines" (Line 38) and exposes --result-json/--usage-json, but there's no unified --json flag and no logic to auto-emit JSON when os.Stdout isn't a TTY. AI hosts spawning praxis run as a subprocess without passing the JSON flags explicitly won't get parseable output.

As per coding guidelines, "If a command has parseable JSON output, support --json and auto-emit JSON when os.Stdout is not a TTY so AI hosts spawning praxis as a subprocess always get parseable output."

Also applies to: 91-108

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/run.go` around lines 24 - 30, Update the run command’s option definitions
and output-selection logic to add a unified --json flag and automatically select
parseable JSON output when os.Stdout is not a TTY. Integrate this behavior with
the existing runResultJSON and runUsageJSON paths so explicit flags remain
supported while machine consumers receive JSON by default.

Source: Coding guidelines

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid per the repo guidelines. --result-json already covers the explicit machine-consumer case. Adding a unified --json flag + non-TTY auto-detection is a good follow-up enhancement, but out of scope for this integration PR (the harness's native.Run owns the JSON encoding logic — wiring auto-detection through the cobra layer requires plumbing a TTY check into the bridge). Tracked for the next iteration.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vishnukv-facets, understood. The finding remains valid but is appropriately deferred from this integration PR given the harness-owned output path. Would you like me to open a GitHub follow-up issue for unified --json support and non-TTY JSON auto-selection?

)

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)
}
Loading