fruxon 0.5.2__tar.gz → 0.5.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {fruxon-0.5.2 → fruxon-0.5.4}/PKG-INFO +2 -1
  2. {fruxon-0.5.2 → fruxon-0.5.4}/pyproject.toml +6 -0
  3. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/_version.py +2 -2
  4. fruxon-0.5.4/src/fruxon/cli/__init__.py +236 -0
  5. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/cli/agents.py +134 -26
  6. fruxon-0.5.4/src/fruxon/cli/auth.py +369 -0
  7. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/cli/chat.py +24 -153
  8. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/cli/doctor.py +47 -4
  9. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/cli/run.py +485 -307
  10. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/cli/trace.py +24 -12
  11. fruxon-0.5.4/src/fruxon/cli_auth.py +183 -0
  12. fruxon-0.5.4/src/fruxon/credentials.py +322 -0
  13. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/doctor.py +15 -6
  14. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/ui.py +118 -5
  15. fruxon-0.5.4/tests/conftest.py +51 -0
  16. {fruxon-0.5.2 → fruxon-0.5.4}/tests/test_cli.py +558 -51
  17. fruxon-0.5.4/tests/test_credentials.py +253 -0
  18. {fruxon-0.5.2 → fruxon-0.5.4}/tests/test_ui.py +98 -0
  19. fruxon-0.5.2/src/fruxon/cli/__init__.py +0 -120
  20. fruxon-0.5.2/src/fruxon/cli/auth.py +0 -239
  21. fruxon-0.5.2/src/fruxon/credentials.py +0 -160
  22. fruxon-0.5.2/tests/test_credentials.py +0 -127
  23. {fruxon-0.5.2 → fruxon-0.5.4}/.gitignore +0 -0
  24. {fruxon-0.5.2 → fruxon-0.5.4}/HISTORY.md +0 -0
  25. {fruxon-0.5.2 → fruxon-0.5.4}/LICENSE +0 -0
  26. {fruxon-0.5.2 → fruxon-0.5.4}/README.md +0 -0
  27. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/__init__.py +0 -0
  28. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/__main__.py +0 -0
  29. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/cli/config.py +0 -0
  30. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/cli/export.py +0 -0
  31. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/exceptions.py +0 -0
  32. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/export.py +0 -0
  33. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/fruxon.py +0 -0
  34. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/models.py +0 -0
  35. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/output.py +0 -0
  36. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/params.py +0 -0
  37. {fruxon-0.5.2 → fruxon-0.5.4}/src/fruxon/update_check.py +0 -0
  38. {fruxon-0.5.2 → fruxon-0.5.4}/tests/__init__.py +0 -0
  39. {fruxon-0.5.2 → fruxon-0.5.4}/tests/test_client.py +0 -0
  40. {fruxon-0.5.2 → fruxon-0.5.4}/tests/test_doctor.py +0 -0
  41. {fruxon-0.5.2 → fruxon-0.5.4}/tests/test_export.py +0 -0
  42. {fruxon-0.5.2 → fruxon-0.5.4}/tests/test_fruxon.py +0 -0
  43. {fruxon-0.5.2 → fruxon-0.5.4}/tests/test_output.py +0 -0
  44. {fruxon-0.5.2 → fruxon-0.5.4}/tests/test_params.py +0 -0
  45. {fruxon-0.5.2 → fruxon-0.5.4}/tests/test_update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fruxon
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: The Fruxon SDK is a lightweight Python client for integrating with the Fruxon platform.
5
5
  Project-URL: bugs, https://github.com/fruxon-ai/fruxon-sdk/issues
6
6
  Project-URL: changelog, https://github.com/fruxon-ai/fruxon-sdk/blob/main/HISTORY.md
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
20
  Requires-Python: >=3.10
21
+ Requires-Dist: keyring>=24.0
21
22
  Requires-Dist: typer>=0.13
22
23
  Provides-Extra: test
23
24
  Requires-Dist: coverage; extra == 'test'
@@ -69,6 +69,12 @@ dependencies = [
69
69
  # ``--help`` invocation when paired with click 8.2+, which other
70
70
  # packages routinely pull in. Floor here protects users from that drift.
71
71
  "typer>=0.13",
72
+ # OS-keychain backed credential storage for ``fruxon login`` — macOS
73
+ # Keychain, libsecret/KWallet on Linux, Credential Manager on Windows.
74
+ # We use it via thin shims around the same shape we already write to
75
+ # ``~/.fruxon/credentials`` so callers behind a corp policy that
76
+ # disables the keyring fall back transparently to the file path.
77
+ "keyring>=24.0",
72
78
  ]
73
79
  requires-python = ">= 3.10"
74
80
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.5.2'
22
- __version_tuple__ = version_tuple = (0, 5, 2)
21
+ __version__ = version = '0.5.4'
22
+ __version_tuple__ = version_tuple = (0, 5, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,236 @@
1
+ """Fruxon CLI · entry point.
2
+
3
+ The Typer ``app`` is the singleton every command registers against. Each
4
+ command lives in its own module under ``fruxon.cli.*`` and imports the
5
+ shared ``app`` from here. Importing those modules at package load is what
6
+ binds their ``@app.command()`` decorators to ``app``.
7
+
8
+ The packaging entry point points at ``fruxon.cli:app`` (see
9
+ ``pyproject.toml`` ``[project.scripts]``).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import atexit
15
+ import json
16
+ import sys
17
+ from typing import Annotated
18
+
19
+ import typer
20
+ from typer.core import TyperGroup
21
+
22
+ from fruxon import credentials
23
+ from fruxon.ui import (
24
+ dashboard_url,
25
+ get_version,
26
+ is_agent_mode,
27
+ maybe_print_update_notice,
28
+ print_banner,
29
+ print_welcome,
30
+ say_err,
31
+ )
32
+
33
+
34
+ class _FruxonTyperGroup(TyperGroup):
35
+ """Branded replacement for Typer's stock ``no such command`` error.
36
+
37
+ Click's default renders the failure as a bordered ASCII box that
38
+ looks nothing like the rest of Fruxon's UI:
39
+
40
+ ╭─ Error ──────────────────────────────────╮
41
+ │ No such command 'foobar'. │
42
+ ╰──────────────────────────────────────────╯
43
+
44
+ We override ``get_command`` to catch the miss before Click raises,
45
+ print the Fruxon-styled ``✗`` line + a "did you mean?" suggestion
46
+ via :mod:`difflib`, then exit. Same exit code (2 — Click's
47
+ ``UsageError.exit_code``) so scripts checking ``$?`` see the same
48
+ signal.
49
+ """
50
+
51
+ def get_command(self, ctx, cmd_name):
52
+ cmd = super().get_command(ctx, cmd_name)
53
+ if cmd is not None:
54
+ return cmd
55
+
56
+ # Branded error path. Build a "did you mean?" suggestion from
57
+ # the registered subcommand names — same difflib pattern we use
58
+ # for agent IDs so the UX feels consistent.
59
+ import difflib
60
+
61
+ candidates = list(self.commands.keys())
62
+ suggestions = difflib.get_close_matches(cmd_name, candidates, n=1, cutoff=0.6)
63
+ if suggestions:
64
+ hint = (
65
+ f"Did you mean [bold]fruxon {suggestions[0]}[/bold]? "
66
+ "Run [bold]fruxon --help[/bold] for the full command list."
67
+ )
68
+ else:
69
+ hint = "Run [bold]fruxon --help[/bold] for the full command list."
70
+ say_err(f"Unknown command [bold]{cmd_name}[/bold].", hint=hint)
71
+ ctx.exit(2)
72
+ return None # unreachable but keeps the type checker happy
73
+
74
+
75
+ # Print the "newer fruxon is available" notice (if any) at the tail of
76
+ # every invocation. Registered at CLI load so it fires regardless of
77
+ # which subcommand ran, including --help and error exits. The notifier
78
+ # already swallows every exception internally — atexit handlers must
79
+ # never raise, so this is the right place.
80
+ atexit.register(maybe_print_update_notice)
81
+
82
+ app = typer.Typer(
83
+ help="Fruxon CLI · tools for working with the Fruxon platform.",
84
+ pretty_exceptions_enable=False,
85
+ cls=_FruxonTyperGroup,
86
+ )
87
+
88
+
89
+ @app.callback(invoke_without_command=True)
90
+ def main(
91
+ ctx: typer.Context,
92
+ version: Annotated[
93
+ bool,
94
+ typer.Option("--version", "-V", help="Print the CLI version and exit."),
95
+ ] = False,
96
+ ):
97
+ """Fruxon CLI · agent execution, export, and platform tooling.
98
+
99
+ Credentials are resolved in this order (first non-empty wins):
100
+ 1. Explicit flags (--api-key / --org / --base-url)
101
+ 2. Environment variables (FRUXON_API_KEY / FRUXON_ORG / FRUXON_BASE_URL)
102
+ 3. The credentials file managed by `fruxon login` (~/.fruxon/credentials)
103
+
104
+ Environment:
105
+ FRUXON_API_KEY Default API key for `fruxon run`.
106
+ FRUXON_ORG Default organization.
107
+ FRUXON_BASE_URL Override the API base URL (staging / self-hosted).
108
+ FRUXON_CONFIG_DIR Override the credentials directory (default ~/.fruxon).
109
+ FRUXON_DASHBOARD_URL Where `fruxon login` points the browser.
110
+ FRUXON_GLYPH Override the brand glyph (default: ✻).
111
+ FRUXON_BRAND_COLOR Override the brand color (e.g. `#7c5cff`, `cyan`).
112
+ FRUXON_NO_BANNER=1 Suppress all branding chrome.
113
+ FRUXON_AGENT_MODE=1 Optimize for AI-agent / script consumption — no
114
+ banner, JSON manifest on bare invocation. Also
115
+ auto-detected from CLAUDECODE=1 / CI=1.
116
+ NO_COLOR=1 Standard convention — disables all color output.
117
+ """
118
+ if version:
119
+ print_banner()
120
+ raise typer.Exit()
121
+
122
+ if ctx.invoked_subcommand is not None:
123
+ return
124
+
125
+ # Bare ``fruxon`` — the highest-leverage moment in the whole UX.
126
+ # First-run users decide whether to keep going based on what
127
+ # appears here; AI agents pattern-match on it to decide how to
128
+ # use the tool. We serve two surfaces from the same entrypoint:
129
+ #
130
+ # * Agent mode (Claude Code, CI, ``FRUXON_AGENT_MODE=1``) →
131
+ # single-line JSON manifest on stdout describing state +
132
+ # next-step commands. No banner, no chrome. An LLM reads
133
+ # this once and is fluent in Fruxon.
134
+ # * Human mode → branded welcome with state-aware CTAs.
135
+ # Branching on signed-in vs not surfaces the right next door:
136
+ # first-time users see "sign in" before everything else;
137
+ # signed-in users see the productive paths instead.
138
+ creds = credentials.load()
139
+ signed_in = bool(credentials.resolve_api_key(None))
140
+
141
+ if is_agent_mode():
142
+ _print_agent_manifest(creds=creds, signed_in=signed_in)
143
+ raise typer.Exit()
144
+
145
+ _print_human_welcome(creds=creds, signed_in=signed_in)
146
+ raise typer.Exit()
147
+
148
+
149
+ def _print_human_welcome(*, creds: credentials.StoredCredentials, signed_in: bool) -> None:
150
+ """Branded welcome for a person sitting at the terminal.
151
+
152
+ Tone target: Claude Code's startup card — one tagline, two
153
+ state-aware CTAs, one universal escape hatch (``--help``).
154
+ Deliberately NOT dumping the Typer commands table — that's
155
+ a wall of duplicated info one keystroke away via ``--help``.
156
+ """
157
+ tagline = "Run, build, and orchestrate AI agents from your terminal."
158
+
159
+ lines: list[str] = [tagline, ""]
160
+ if signed_in:
161
+ org_label = f"org [bold]{creds.org}[/bold]" if creds.org else "no default org"
162
+ lines.append(f"[green]✓[/green] Signed in · {org_label}")
163
+ lines.append("")
164
+ lines.append("Try: [bold]fruxon agents list[/bold] — browse what's deployed")
165
+ lines.append(" [bold]fruxon run[/bold] <agent> — execute one")
166
+ lines.append(" [bold]fruxon chat[/bold] <agent> — open an interactive REPL")
167
+ else:
168
+ lines.append("[yellow]›[/yellow] Not signed in")
169
+ lines.append("")
170
+ lines.append("Sign in: [bold]fruxon login[/bold]")
171
+ lines.append(f"No account? Visit [link]{dashboard_url()}[/link]")
172
+
173
+ lines.append("")
174
+ lines.append("All commands: [bold]fruxon --help[/bold]")
175
+ lines.append("Diagnostics: [bold]fruxon doctor[/bold]")
176
+
177
+ print_welcome(tips=lines)
178
+
179
+
180
+ def _print_agent_manifest(*, creds: credentials.StoredCredentials, signed_in: bool) -> None:
181
+ """One-line JSON manifest for AI agents and scripts.
182
+
183
+ The shape is intentionally tiny and stable: name, version,
184
+ auth state, and a ``next`` object pointing at the commands an
185
+ LLM should reach for. The goal is "an LLM reads this once and
186
+ knows what to do" — not a full schema dump (that's the future
187
+ ``fruxon describe``).
188
+
189
+ Stdout, not stderr — agents expect their parseable signal on
190
+ stdout. JSON one-liner so it composes cleanly with ``| jq``.
191
+ """
192
+ manifest = {
193
+ "name": "fruxon",
194
+ "version": get_version(),
195
+ "signed_in": signed_in,
196
+ "org": creds.org if signed_in else None,
197
+ "next": (
198
+ {
199
+ "agents": "fruxon agents list --output json",
200
+ "run": "fruxon run <agent> -p key=value --output json",
201
+ "chat": "fruxon chat <agent>",
202
+ "trace": "fruxon trace <agent> <record-id> --output json",
203
+ "help": "fruxon --help",
204
+ }
205
+ if signed_in
206
+ else {
207
+ "login": "fruxon login --api-key <KEY>",
208
+ "dashboard": dashboard_url(),
209
+ "help": "fruxon --help",
210
+ }
211
+ ),
212
+ }
213
+ sys.stdout.write(json.dumps(manifest) + "\n")
214
+ sys.stdout.flush()
215
+
216
+
217
+ # ─────────────────────────────────────────────────────────────────────────────
218
+ # Command registration
219
+ # ─────────────────────────────────────────────────────────────────────────────
220
+ #
221
+ # Importing the command modules below executes their ``@app.command()`` and
222
+ # ``app.add_typer(...)`` calls, wiring every subcommand into the singleton
223
+ # above. The order doesn't matter for correctness — only for the order they
224
+ # show up in ``fruxon --help``.
225
+
226
+ from fruxon.cli import agents as _agents # noqa: E402, F401
227
+ from fruxon.cli import auth as _auth # noqa: E402, F401
228
+ from fruxon.cli import chat as _chat # noqa: E402, F401
229
+ from fruxon.cli import config as _config # noqa: E402, F401
230
+ from fruxon.cli import doctor as _doctor # noqa: E402, F401
231
+ from fruxon.cli import export as _export # noqa: E402, F401
232
+ from fruxon.cli import run as _run # noqa: E402, F401
233
+ from fruxon.cli import trace as _trace # noqa: E402, F401
234
+
235
+ if __name__ == "__main__":
236
+ app()
@@ -18,7 +18,15 @@ from fruxon import credentials
18
18
  from fruxon.cli import app
19
19
  from fruxon.exceptions import FruxonError
20
20
  from fruxon.fruxon import Agent, FruxonClient
21
- from fruxon.ui import print_banner, say_err, say_info, say_ok, stderr
21
+ from fruxon.ui import (
22
+ dashboard_url,
23
+ print_banner,
24
+ resolve_output_format,
25
+ say_err,
26
+ say_info,
27
+ say_ok,
28
+ stderr,
29
+ )
22
30
 
23
31
  agents_app = typer.Typer(
24
32
  help="Browse and inspect agents in your org.",
@@ -73,13 +81,17 @@ def agents_list(
73
81
  ] = None,
74
82
  base_url: Annotated[str | None, typer.Option("--base-url", help="API base URL override.")] = None,
75
83
  output: Annotated[
76
- str,
84
+ str | None,
77
85
  typer.Option(
78
86
  "--output",
79
87
  "-o",
80
- help="Output format: table (default · humans), json (machines), id (one ID per line, pipe-safe).",
88
+ help=(
89
+ "Output format: table (default for humans), json (machines, default in "
90
+ "agent mode), id (one ID per line, pipe-safe). Under FRUXON_AGENT_MODE / "
91
+ "CLAUDECODE / CI the implicit default flips to json."
92
+ ),
81
93
  ),
82
- ] = "table",
94
+ ] = None,
83
95
  disabled: Annotated[
84
96
  bool,
85
97
  typer.Option(
@@ -99,6 +111,7 @@ def agents_list(
99
111
  fruxon agents list --output json
100
112
  fruxon agents list --output id | xargs -n1 fruxon run
101
113
  """
114
+ output = resolve_output_format(output, human_default="table", agent_default="json")
102
115
  if output not in {"table", "json", "id"}:
103
116
  say_err(
104
117
  f"Unknown output format: [bold]{output}[/bold]",
@@ -120,16 +133,44 @@ def agents_list(
120
133
  say_err(str(e), hint=_hint_for_list_error(e))
121
134
  raise typer.Exit(code=1) from e
122
135
 
123
- if not disabled:
124
- agents = [a for a in agents if a.enabled]
125
-
126
- if not agents:
136
+ # Distinguish the two "empty" cases so the hint actually fits the
137
+ # situation. A first-time user with zero agents in their org needs
138
+ # to be pointed at the dashboard; a user whose agents are all
139
+ # disabled needs to know ``--all`` exists. Same flat "No agents
140
+ # to show" message used to swallow both — useless in both cases.
141
+ all_agents = agents
142
+ visible_agents = all_agents if disabled else [a for a in all_agents if a.enabled]
143
+
144
+ if not visible_agents:
145
+ if not all_agents:
146
+ # Truly empty org — onboarding moment, not a filter quirk.
147
+ # JSON callers get an empty array (already returned above
148
+ # in the output branches below) so this say_info is purely
149
+ # for the human-facing path.
150
+ if output == "json":
151
+ print("[]")
152
+ return
153
+ if output == "id":
154
+ return
155
+ say_info(
156
+ "No agents deployed in this org yet.",
157
+ hint=f"Create one in the dashboard: [link]{dashboard_url()}[/link]",
158
+ )
159
+ return
160
+ # Some agents exist but every one is disabled.
161
+ if output == "json":
162
+ print("[]")
163
+ return
164
+ if output == "id":
165
+ return
127
166
  say_info(
128
- "No agents to show.",
129
- hint="Try [bold]--all[/bold] to include disabled agents, or create one in the dashboard.",
167
+ "All agents in this org are disabled.",
168
+ hint="Use [bold]--all[/bold] to include disabled agents.",
130
169
  )
131
170
  return
132
171
 
172
+ agents = visible_agents
173
+
133
174
  if output == "id":
134
175
  # One ID per line on stdout for trivial shell composition.
135
176
  for a in agents:
@@ -155,21 +196,37 @@ def agents_get(
155
196
  typer.Option("--api-key", "-k", envvar="FRUXON_API_KEY", help="API key override."),
156
197
  ] = None,
157
198
  base_url: Annotated[str | None, typer.Option("--base-url", help="API base URL override.")] = None,
158
- json_output: Annotated[
159
- bool,
160
- typer.Option("--json", help="Output the agent as JSON instead of human-readable rows."),
161
- ] = False,
199
+ output: Annotated[
200
+ str | None,
201
+ typer.Option(
202
+ "--output",
203
+ "-o",
204
+ help=(
205
+ "Output format: text (default for humans), json (machines, default in "
206
+ "agent mode). Under FRUXON_AGENT_MODE / CLAUDECODE / CI the implicit "
207
+ "default flips to json."
208
+ ),
209
+ ),
210
+ ] = None,
162
211
  ):
163
212
  """Show one agent's details.
164
213
 
165
214
  Examples:
166
215
  fruxon agents get support-agent
167
- fruxon agents get support-agent --json
216
+ fruxon agents get support-agent --output json
168
217
  """
218
+ output = resolve_output_format(output, human_default="text", agent_default="json")
219
+ if output not in {"text", "json"}:
220
+ say_err(
221
+ f"Unknown output format: [bold]{output}[/bold]",
222
+ hint="Valid formats: text, json.",
223
+ )
224
+ raise typer.Exit(code=1)
225
+
169
226
  client = _build_client(org, api_key, base_url)
170
227
 
171
228
  try:
172
- if stderr.is_terminal and not json_output:
229
+ if stderr.is_terminal and output == "text":
173
230
  with stderr.status(f"[bold]Fetching agent [cyan]{agent}[/cyan]…[/bold]"):
174
231
  fetched = client.get_agent(agent)
175
232
  parameters = _fetch_parameters_safely(client, fetched)
@@ -177,10 +234,10 @@ def agents_get(
177
234
  fetched = client.get_agent(agent)
178
235
  parameters = _fetch_parameters_safely(client, fetched)
179
236
  except FruxonError as e:
180
- say_err(str(e), hint=_hint_for_get_error(e, agent))
237
+ say_err(str(e), hint=_hint_for_get_error(e, agent, client))
181
238
  raise typer.Exit(code=1) from e
182
239
 
183
- if json_output:
240
+ if output == "json":
184
241
  payload = _agent_to_dict(fetched)
185
242
  payload["parameters"] = parameters
186
243
  print(json.dumps(payload, indent=2))
@@ -235,6 +292,19 @@ def _fetch_parameters_safely(client: FruxonClient, fetched: Agent) -> list[str]:
235
292
  def _build_table(agents: list[Agent]):
236
293
  """Render an agent list as a rich Table.
237
294
 
295
+ Column shape is tuned for readability across terminal widths:
296
+
297
+ * **ID** — the must-have. ``no_wrap=True`` keeps each row on one
298
+ line; long IDs truncate with ``…`` rather than wrapping into
299
+ multi-line entries that destroy scannability.
300
+ * **Name** — wraps with ellipsis overflow if needed.
301
+ * **Status** — fixed-width, near the ID so it stays visible even
302
+ when narrow widths force right-side columns to shrink.
303
+ * **Rev** — small right-aligned column.
304
+ * **Tags** — last because it's the first column to truncate.
305
+ Hard-capped at 24 chars; previously wrapped into 4+ line entries
306
+ on agents with many tags, which made the table unreadable.
307
+
238
308
  Imported lazily so callers that don't render a table (``--output id``,
239
309
  ``--output json``) don't pay for the Table machinery.
240
310
  """
@@ -246,19 +316,37 @@ def _build_table(agents: list[Agent]):
246
316
  show_edge=False,
247
317
  pad_edge=False,
248
318
  box=None,
249
- padding=(0, 2),
319
+ # ``padding=(0, 1)`` gives one column of gap between cells.
320
+ # With 5 columns the inter-cell gaps consume 4 chars total,
321
+ # leaving more room for content on narrow terminals (80 col
322
+ # ssh sessions, tmux panes, etc.) before Rich starts
323
+ # shrinking columns into ``…``.
324
+ padding=(0, 1),
250
325
  )
251
- table.add_column("ID", style="bold")
252
- table.add_column("Name")
253
- table.add_column("Rev", justify="right", style="dim")
254
- table.add_column("Tags", style="dim")
255
- table.add_column("Status", justify="right")
326
+ # ``no_wrap=True`` on every column forces single-line-per-row.
327
+ # Combined with ``overflow="ellipsis"`` Rich truncates cells that
328
+ # overflow the column width with ``…`` instead of wrapping —
329
+ # critical for table scannability when an agent has many tags or
330
+ # a long display name.
331
+ #
332
+ # Status and Rev sit right after ID (the eye-tracking priority)
333
+ # and use ``no_wrap`` without a max so they always render in
334
+ # full. ID / Name / Tags carry ``max_width`` to bound how much
335
+ # space each grabs on a wide terminal and to anchor the
336
+ # ellipsis-truncation point on narrow ones. Old layout used to
337
+ # let Tags steal the row and wrap across 8 lines for agents with
338
+ # many tags.
339
+ table.add_column("ID", style="bold", no_wrap=True, overflow="ellipsis", max_width=24)
340
+ table.add_column("Status", justify="right", no_wrap=True)
341
+ table.add_column("Rev", justify="right", style="dim", no_wrap=True)
342
+ table.add_column("Name", no_wrap=True, overflow="ellipsis", max_width=20)
343
+ table.add_column("Tags", style="dim", no_wrap=True, overflow="ellipsis", max_width=22)
256
344
 
257
345
  for a in agents:
258
346
  status = "[green]enabled[/green]" if a.enabled else "[dim]disabled[/dim]"
259
347
  rev = str(a.current_revision) if a.current_revision else "—"
260
348
  tags = ", ".join(a.tags) if a.tags else "—"
261
- table.add_row(a.id, a.display_name or "[dim]—[/dim]", rev, tags, status)
349
+ table.add_row(a.id, status, rev, a.display_name or "[dim]—[/dim]", tags)
262
350
  return table
263
351
 
264
352
 
@@ -292,10 +380,30 @@ def _hint_for_list_error(error: FruxonError) -> str | None:
292
380
  return None
293
381
 
294
382
 
295
- def _hint_for_get_error(error: FruxonError, agent: str) -> str | None:
383
+ def _hint_for_get_error(error: FruxonError, agent: str, client: FruxonClient) -> str | None:
384
+ """Hint generator for ``agents get``.
385
+
386
+ NotFound is the high-traffic case — typos in agent IDs are common.
387
+ We fetch the org's agent list and use ``difflib.get_close_matches``
388
+ to surface a "did you mean?" suggestion. Network errors during
389
+ that lookup are swallowed: the user is already in an error path
390
+ and a second failure on top would be hostile.
391
+ """
296
392
  from fruxon.exceptions import NotFoundError
297
393
 
298
394
  if isinstance(error, NotFoundError):
395
+ import difflib
396
+
397
+ try:
398
+ candidates = [a.id for a in client.list_agents()]
399
+ suggestions = difflib.get_close_matches(agent, candidates, n=1, cutoff=0.6)
400
+ except Exception:
401
+ suggestions = []
402
+ if suggestions:
403
+ return (
404
+ f"Did you mean [bold]{suggestions[0]}[/bold]? "
405
+ f"Run with: [bold]fruxon agents get {suggestions[0]}[/bold]"
406
+ )
299
407
  return (
300
408
  f"No agent matches [bold]{agent}[/bold] in this org. "
301
409
  "Try [bold]fruxon agents list[/bold] to see what's available."