Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,12 @@ Initial implementation.
`--dangerously-bypass-approvals-and-sandbox`, pi's full toolset — and
surface it in the notes, the run journal and `doctor`. Without consent,
permission requests still fail closed (exit 2) with guidance.
- Every remaining print path now respects the terminal width: the
full-permission and standalone-profile warnings, the run summary's
harness-error block (source indentation from harness error boxes is
stripped so re-wrapped lines start at the left edge) and artifact lines,
doctor details, init step logs, and the skills table (which drops its
description column below ~77 columns instead of overflowing).
- Interactive screens now gate on a real terminal check (ioctl) instead of
"is a character device": `traust-cli skills < /dev/null` used to open the
picker and hang forever, because /dev/null is a character device.
6 changes: 5 additions & 1 deletion cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"encoding/json"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"

"github.com/traust-security/traust-cli/internal/bootstrap"
"github.com/traust-security/traust-cli/internal/theme"
"github.com/traust-security/traust-cli/internal/tui"
)

var doctorJSON bool
Expand Down Expand Up @@ -50,7 +52,9 @@ func runDoctor(cmd *cobra.Command, args []string) {
}
fmt.Printf(" %s %s\n", mark, c.Name)
if c.Detail != "" {
fmt.Println(styles.Muted.Render(" " + c.Detail))
// Details can be multi-line and long (subprocess output):
// normalize the block and wrap it to the terminal width.
fmt.Println(styles.Muted.Render(indent(tui.Wrap(strings.TrimSpace(c.Detail), tui.TerminalWidth()-8), " ")))
}
}
}
Expand Down
14 changes: 6 additions & 8 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"

"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/traust-security/traust-cli/internal/bootstrap"
"github.com/traust-security/traust-cli/internal/harness"
Expand Down Expand Up @@ -91,7 +92,7 @@ func runInit(cmd *cobra.Command, args []string) {
}

log := func(format string, a ...any) {
fmt.Println(styles.Accent.Render(fmt.Sprintf(format, a...)))
fmt.Println(tui.Wrap(styles.Accent.Render(fmt.Sprintf(format, a...)), tui.TerminalWidth()))
}

state, err := bootstrap.Init(ctx, bootstrap.InitOptions{
Expand Down Expand Up @@ -149,12 +150,9 @@ func shortSHA(sha string) string {
}

// isTTY reports whether the CLI can render interactive screens: both stdin
// and stdout must be character devices (piped output means plain mode).
// and stdout must be real terminals. The check is ioctl-based — /dev/null is
// a character device but not a terminal, and treating it as one hangs the
// picker waiting for input that will never arrive.
func isTTY() bool {
in, err := os.Stdin.Stat()
if err != nil || in.Mode()&os.ModeCharDevice == 0 {
return false
}
out, err := os.Stdout.Stat()
return err == nil && out.Mode()&os.ModeCharDevice != 0
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
}
29 changes: 24 additions & 5 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,11 @@ func executeRun(state *bootstrap.State, skill *catalog.Skill, args []string) {
// recorded in state. The harness's skills require unattended tool use.
unsafeFullPermission := runFlags.unsafeFullPermission || state.FullPermission
if unsafeFullPermission && !runFlags.unsafeFullPermission {
fmt.Fprintln(os.Stderr, theme.Default().Warn.Render("note: full-permission mode active (consented at init; re-run `traust-cli init` to change) — harness runs with all tools auto-approved"))
warnNote("full-permission mode active (consented at init; re-run `traust-cli init` to change) — harness runs with all tools auto-approved")
}

if skill.Standalone == nil {
styles := theme.Default()
fmt.Fprintln(os.Stderr, styles.Warn.Render("warning: "+fmt.Sprintf("%q is not in the standalone profile — it may expect campaign infrastructure (siblings, ledger, tracker)", skill.Name)))
warnNote(fmt.Sprintf("%q is not in the standalone profile — it may expect campaign infrastructure (siblings, ledger, tracker)", skill.Name))
}

opts := runner.Options{
Expand Down Expand Up @@ -204,7 +203,7 @@ func printRunSummary(res *runner.RunResult) {
if res.HarnessError != "" {
fmt.Println()
fmt.Println(styles.Err.Render("harness error:"))
fmt.Println(indent(res.HarnessError, " "))
fmt.Println(indent(wrapBlock(res.HarnessError, tui.TerminalWidth()-4), " "))
}
if len(res.Artifacts) > 0 {
fmt.Println()
Expand All @@ -217,7 +216,7 @@ func printRunSummary(res *runner.RunResult) {
if a.Rendered != "" {
extra = styles.Muted.Render(" (rendered → " + a.Rendered + ")")
}
fmt.Printf(" %s %s%s\n", mark, a.Path, extra)
fmt.Println(tui.Wrap(mark+" "+a.Path+extra, tui.TerminalWidth()))
}
}
if res.ResultText != "" {
Expand All @@ -227,6 +226,21 @@ func printRunSummary(res *runner.RunResult) {
}
}

// wrapBlock normalizes a multi-line block for display: trailing whitespace
// is stripped, leading indentation from source formatting (e.g. a harness's
// styled error box) is dropped so re-wrapped lines start at the left edge,
// and every line wraps to width.
func wrapBlock(s string, width int) string {
if width < 10 {
width = 10
}
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
for i, l := range lines {
lines[i] = strings.TrimSpace(l)
}
return tui.Wrap(strings.Join(lines, "\n"), width)
}

func indent(s, prefix string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
for i, l := range lines {
Expand All @@ -235,6 +249,11 @@ func indent(s, prefix string) string {
return strings.Join(lines, "\n")
}

// warnNote prints a wrapped warning to stderr.
func warnNote(msg string) {
fmt.Fprintln(os.Stderr, tui.Wrap(theme.Default().Warn.Render("warning: "+msg), tui.TerminalWidth()))
}

// plainSink prints run updates as styled plain text (non-TTY or --no-monitor).
type plainSink struct{}

Expand Down
39 changes: 39 additions & 0 deletions cmd/run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package cmd

import (
"strings"
"testing"

"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)

func TestWrapBlockStripsIndentationAndWraps(t *testing.T) {
lipgloss.SetColorProfile(termenv.TrueColor)
block := "exit status 3\n ERROR Agent processing failed: 401 Unauthorized invalid x-api-key \n"
out := wrapBlock(block, 40)

for _, line := range strings.Split(out, "\n") {
if w := lipgloss.Width(line); w > 40 {
t.Errorf("line is %d cells wide (limit 40): %q", w, line)
}
// Harness error boxes arrive with leading decoration; the display
// form must start at the left edge so re-wrapped lines align.
if strings.HasPrefix(line, " ") {
t.Errorf("line retains leading whitespace: %q", line)
}
}
if !strings.Contains(out, "exit status 3") {
t.Errorf("content lost: %q", out)
}
if strings.Contains(out, " \n") || strings.HasSuffix(out, " ") {
t.Errorf("trailing whitespace not stripped: %q", out)
}
}

func TestWrapBlockPreservesNewlines(t *testing.T) {
out := wrapBlock("short one\nshort two", 40)
if out != "short one\nshort two" {
t.Errorf("short lines should pass through: %q", out)
}
}
32 changes: 23 additions & 9 deletions cmd/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,23 +99,37 @@ func runSkills(cmd *cobra.Command, args []string) {
}
return skills[i].Name < skills[j].Name
})
fmt.Printf(" %-28s %-15s %-11s %s\n", styles.Label.Render("SKILL"), styles.Label.Render("STAGE"), styles.Label.Render("MIN MODEL"), styles.Label.Render("DESCRIPTION"))
for _, s := range skills {
minModel := "—"
if s.Standalone != nil && s.Standalone.MinModelClass != "" {
minModel = s.Standalone.MinModelClass
// Column layout adapts to the terminal. Below ~77 columns there is no
// room for a description column, so the table drops it rather than
// pushing rows past the right edge.
width := tui.TerminalWidth()
avail := width - 2
if avail < 28+15+11+3+20 {
fmt.Printf(" %-28s %-15s %-11s\n", styles.Label.Render("SKILL"), styles.Label.Render("STAGE"), styles.Label.Render("MIN MODEL"))
for _, s := range skills {
fmt.Printf(" %-28s %-15s %-11s\n",
tui.Truncate(s.Name, 28, "…"), tui.Truncate(orDash(s.Stage), 15, "…"), minModelOf(s))
}
desc := s.Description
if len(desc) > 70 {
desc = desc[:69] + "…"
} else {
descWidth := avail - 28 - 15 - 11 - 3
fmt.Printf(" %-28s %-15s %-11s %s\n", styles.Label.Render("SKILL"), styles.Label.Render("STAGE"), styles.Label.Render("MIN MODEL"), styles.Label.Render("DESCRIPTION"))
for _, s := range skills {
fmt.Printf(" %-28s %-15s %-11s %s\n",
tui.Truncate(s.Name, 28, "…"), tui.Truncate(orDash(s.Stage), 15, "…"), minModelOf(s), tui.Truncate(s.Description, descWidth, "…"))
}
fmt.Printf(" %-28s %-15s %-11s %s\n", s.Name, orDash(s.Stage), minModel, desc)
}
fmt.Println()
fmt.Println(styles.Muted.Render("run one: traust-cli run <skill> [args…] --harness <id>"))
os.Exit(0)
}

func minModelOf(s *catalog.Skill) string {
if s.Standalone != nil && s.Standalone.MinModelClass != "" {
return s.Standalone.MinModelClass
}
return "—"
}

func orDash(s string) string {
if s == "" {
return "—"
Expand Down
Loading