fcloud-sdk 0.2.0__tar.gz → 0.2.1__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 (149) hide show
  1. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/CHANGELOG.md +46 -0
  2. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/PKG-INFO +20 -9
  3. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/README.md +19 -8
  4. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/SKILL.md +7 -1
  5. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/pyproject.toml +1 -1
  6. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/SKILL.md +7 -1
  7. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/__main__.py +1 -0
  8. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/_direct_bridge.py +5 -0
  9. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/help.py +4 -3
  10. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/job.py +59 -4
  11. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/processes.py +34 -1
  12. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/sessions.py +124 -10
  13. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/setup.py +33 -34
  14. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/sweep_harvest.py +10 -1
  15. fcloud_sdk-0.2.1/src/fcloud/cli/usage.py +67 -0
  16. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/volume.py +5 -1
  17. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/wait.py +7 -0
  18. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli_args.py +2 -1
  19. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/client.py +24 -0
  20. fcloud_sdk-0.2.1/src/fcloud/login.py +236 -0
  21. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/setup_cmd.py +15 -3
  22. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/PKG-INFO +20 -9
  23. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/SOURCES.txt +6 -0
  24. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_direct_bridge.py +23 -0
  25. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_first_run_login.py +18 -15
  26. fcloud_sdk-0.2.1/tests/test_job_ls.py +99 -0
  27. fcloud_sdk-0.2.1/tests/test_login.py +202 -0
  28. fcloud_sdk-0.2.1/tests/test_logs_unknown_pid.py +84 -0
  29. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sessions_live_statuses.py +17 -0
  30. fcloud_sdk-0.2.1/tests/test_spend.py +203 -0
  31. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_harvest.py +27 -0
  32. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_volume_cli_hardening.py +9 -0
  33. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_wait.py +26 -0
  34. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/CONTRIBUTING.md +0 -0
  35. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/LICENSE +0 -0
  36. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/MANIFEST.in +0 -0
  37. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/NOTICE +0 -0
  38. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/setup.cfg +0 -0
  39. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/__init__.py +0 -0
  40. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/_legacy_env.py +0 -0
  41. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/autoresearch/SETUP.md +0 -0
  42. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/autoresearch/program.template.md +0 -0
  43. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/__init__.py +0 -0
  44. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/attach.py +0 -0
  45. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/autoresearch.py +0 -0
  46. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/common.py +0 -0
  47. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/config_cmd.py +0 -0
  48. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/console.py +0 -0
  49. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/context.py +0 -0
  50. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/exec_cmd.py +0 -0
  51. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/files.py +0 -0
  52. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/hardware.py +0 -0
  53. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/interactive.py +0 -0
  54. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/main.py +0 -0
  55. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/migration.py +0 -0
  56. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/mount.py +0 -0
  57. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/output.py +0 -0
  58. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/registry.py +0 -0
  59. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/run.py +0 -0
  60. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/ssh.py +0 -0
  61. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/sweep.py +0 -0
  62. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/sweep_watch.py +0 -0
  63. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/client_projects.py +0 -0
  64. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/client_sessions.py +0 -0
  65. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/client_volumes.py +0 -0
  66. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/config.py +0 -0
  67. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/direct.py +0 -0
  68. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/errors.py +0 -0
  69. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/fileset.py +0 -0
  70. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/image.py +0 -0
  71. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/job.py +0 -0
  72. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/providers/__init__.py +0 -0
  73. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/providers/requests_http.py +0 -0
  74. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/py.typed +0 -0
  75. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/session.py +0 -0
  76. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/shell.py +0 -0
  77. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/sweeps.py +0 -0
  78. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/tunnel.py +0 -0
  79. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/types.py +0 -0
  80. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/v2_connect.py +0 -0
  81. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/version.py +0 -0
  82. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/volume_wait.py +0 -0
  83. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/volumes.py +0 -0
  84. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/dependency_links.txt +0 -0
  85. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/entry_points.txt +0 -0
  86. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/requires.txt +0 -0
  87. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/top_level.txt +0 -0
  88. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/foom/__init__.py +0 -0
  89. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/conftest.py +0 -0
  90. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_autoresearch.py +0 -0
  91. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_checkpoint_option.py +0 -0
  92. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_cli_args.py +0 -0
  93. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_cli_dispatch.py +0 -0
  94. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_cli_guards.py +0 -0
  95. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_cli_help.py +0 -0
  96. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_client.py +0 -0
  97. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_client_contracts_c.py +0 -0
  98. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_client_host_frames_contract.py +0 -0
  99. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_client_http_contract.py +0 -0
  100. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_client_telemetry.py +0 -0
  101. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_config.py +0 -0
  102. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_config_cmd.py +0 -0
  103. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_default_image.py +0 -0
  104. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_direct_fake_host.py +0 -0
  105. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_dotenv_precedence.py +0 -0
  106. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_download_volume_hint.py +0 -0
  107. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_emit_pid.py +0 -0
  108. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_exec_attach_retry.py +0 -0
  109. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_fileset.py +0 -0
  110. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_job.py +0 -0
  111. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_kill_records_first.py +0 -0
  112. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_legacy_shim.py +0 -0
  113. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_map_dag.py +0 -0
  114. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_map_default_image.py +0 -0
  115. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_map_window_flags.py +0 -0
  116. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_no_resume_gate.py +0 -0
  117. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_progress_narration.py +0 -0
  118. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_provider_env_default.py +0 -0
  119. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_queued_reason_render.py +0 -0
  120. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_rate_limit_ride.py +0 -0
  121. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_rebuild_respawn.py +0 -0
  122. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_resume_volume_validation.py +0 -0
  123. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_ride_cap.py +0 -0
  124. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_run_migration.py +0 -0
  125. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_run_volume_collision.py +0 -0
  126. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_run_wait_flags.py +0 -0
  127. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_secret_resolution.py +0 -0
  128. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_session_truth.py +0 -0
  129. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sessions_point_lookup.py +0 -0
  130. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_setup_cmd.py +0 -0
  131. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_shell_env.py +0 -0
  132. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_shell_interrupt.py +0 -0
  133. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_shell_volumes.py +0 -0
  134. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_skus.py +0 -0
  135. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_stop_refused_classification.py +0 -0
  136. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_boost.py +0 -0
  137. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_cost_optin.py +0 -0
  138. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_lifecycle_ux.py +0 -0
  139. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_retry_and_kill_truth.py +0 -0
  140. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_status_view.py +0 -0
  141. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweeps_binding.py +0 -0
  142. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_tunnel.py +0 -0
  143. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_types.py +0 -0
  144. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_upload_s3_path.py +0 -0
  145. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_v2_connect.py +0 -0
  146. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_volume_download_delete.py +0 -0
  147. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_volume_preflight.py +0 -0
  148. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_volume_resolution.py +0 -0
  149. {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_volume_wait.py +0 -0
@@ -4,6 +4,52 @@ All notable changes to the `fcloud` Python SDK and CLI are recorded here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
5
5
  versions follow [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.1] - 2026-09-14
10
+
11
+ ### Added
12
+ - `fcloud login`: sign in through the browser (device authorization). Shows a
13
+ short code, opens the approval page, and saves a key minted for this
14
+ machine (named `cli:<hostname>`); then installs the fcloud skill for the
15
+ coding agents present on the machine (`--agents auto`, the default; also
16
+ `all`, a list, or `none`). `--no-browser` prints the URL and code instead
17
+ of opening a browser. A fresh install's first command starts the same
18
+ login, and `fcloud setup` with no key delegates to it.
19
+ - Spend visibility: `fcloud sessions` shows `$/HR` (the rate billed on the
20
+ session's open usage interval while a host is held, 0 when cold), `SPENT`
21
+ (compute charged so far) and `AGE` (time on its current host) per row plus
22
+ a "Spending now" footer; `--json` rows carry `usd_per_hour`, `spent_usd`
23
+ and `host_age_seconds` (SDK: `Client.session_cost`). New `fcloud usage [--since DUR|TS] [--until TS]`
24
+ reports what the account was charged over a window (default 30 days) by
25
+ resource and session (SDK: `Client.billing_usage`).
26
+
27
+ ### Changed
28
+ - `fcloud job ls` shows each job's COMMAND and EXIT code (from its process
29
+ index; `--json` rows carry `command` and `exit_code`), so rows are
30
+ tellable apart and failures visible without opening each job.
31
+
32
+ ### Fixed
33
+ - `fcloud logs <sid> <pid>` with a pid the dispatcher has no record of now
34
+ exits 1 (`process_not_found`) like `wait` and `kill`, instead of printing
35
+ an empty stream with exit 0 (and `status: ""` in `--json`) that a poller
36
+ read as a finished, silent process. A just-spawned pid gets a few seconds
37
+ to reach the durable index before it is called unknown.
38
+ - `fcloud wait` with a `--timeout` shorter than the unknown-pid grace window
39
+ exits 1 for an unknown pid, not 75 (retry), so scripted callers stop
40
+ retrying a typo forever.
41
+ - Cold starts of `run`/`exec`/`job run` no longer dump every host status
42
+ frame (`⟵ session_status: building (phase_change) setup_timing ...`);
43
+ the fresh-allocation and reconnect paths now use the same quiet printer
44
+ as warm attaches.
45
+ - `fcloud sweep harvest` no longer pulls the session's virtual `_logs/`
46
+ directory (`_logs/processes.json`) for a results glob like `*.json`; name
47
+ `_logs/` in the glob to fetch logs.
48
+ - `fcloud sessions` sizes the HOST and SKU columns to the data (27-char AWS
49
+ host ids overflowed the fixed width on every row).
50
+ - `fcloud volume list` shows each volume's last update time.
51
+ - SKILL.md documents `fcloud sessions --limit N`.
52
+
7
53
  ## [0.2.0] - 2026-09-08
8
54
 
9
55
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fcloud-sdk
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Python SDK and CLI for the fcloud GPU compute platform
5
5
  Author: fcloud
6
6
  License-Expression: Apache-2.0
@@ -43,25 +43,35 @@ Requires Python 3.10+.
43
43
 
44
44
  ## Install
45
45
 
46
+ ```bash
47
+ curl -fsSL https://fcloud-home.vercel.app/install.sh | sh
48
+ ```
49
+
50
+ That installs the CLI from PyPI (via `uv` or `pipx`), signs you in through
51
+ the browser, and installs the fcloud skill for coding agents on your machine.
52
+ With your own Python:
53
+
46
54
  ```bash
47
55
  pip install fcloud-sdk
56
+ fcloud login
48
57
  ```
49
58
 
50
59
  ## Setup
51
60
 
52
- Create an account on https://fcloud-home.vercel.app.
53
- Enter a credit card, and copy your API key.
54
- Or just run any `fcloud` command (or `fcloud login`): a fresh install
55
- walks you through sign-up and saves the key you paste.
61
+ `fcloud login` opens https://fcloud-home.vercel.app to sign in (Google or
62
+ email) and add a card, then saves a key minted for this machine. Running any
63
+ `fcloud` command on a fresh install starts the same login.
56
64
 
57
65
  ```bash
58
- fcloud setup # prompts for the key; optionally installs the agent skill
66
+ fcloud login [--no-browser] [--agents auto|all|cursor|claude|codex|none]
67
+ fcloud set_token <key> # already have a key (CI, agents, a second machine)
68
+ fcloud setup # save a key you already have; install agent skills
59
69
  fcloud health # verify connectivity
60
70
  ```
61
71
 
62
- `fcloud setup` also installs (by default) the fcloud skill file for supported
63
- coding agents (Cursor, Claude Code, Codex) so an agent can drive fcloud for you.
64
- Use `--agents none` to skip that.
72
+ `fcloud login` installs the fcloud skill file for the coding agents it finds
73
+ (Cursor, Claude Code, Codex — `--agents all` for every one) so an agent can
74
+ drive fcloud for you. `fcloud setup --agents none` skips that.
65
75
 
66
76
  ## Quick start
67
77
 
@@ -141,6 +151,7 @@ Batch
141
151
  fcloud sweeps / fcloud sweep <status|logs|retry|cancel|wait> <name>
142
152
 
143
153
  Setup
154
+ fcloud login [--no-browser] [--agents auto|all|cursor|claude|codex|none]
144
155
  fcloud setup [--token KEY] [--agents all|cursor|claude|codex|none]
145
156
  fcloud set_token <api-key>
146
157
  fcloud --version
@@ -9,25 +9,35 @@ Requires Python 3.10+.
9
9
 
10
10
  ## Install
11
11
 
12
+ ```bash
13
+ curl -fsSL https://fcloud-home.vercel.app/install.sh | sh
14
+ ```
15
+
16
+ That installs the CLI from PyPI (via `uv` or `pipx`), signs you in through
17
+ the browser, and installs the fcloud skill for coding agents on your machine.
18
+ With your own Python:
19
+
12
20
  ```bash
13
21
  pip install fcloud-sdk
22
+ fcloud login
14
23
  ```
15
24
 
16
25
  ## Setup
17
26
 
18
- Create an account on https://fcloud-home.vercel.app.
19
- Enter a credit card, and copy your API key.
20
- Or just run any `fcloud` command (or `fcloud login`): a fresh install
21
- walks you through sign-up and saves the key you paste.
27
+ `fcloud login` opens https://fcloud-home.vercel.app to sign in (Google or
28
+ email) and add a card, then saves a key minted for this machine. Running any
29
+ `fcloud` command on a fresh install starts the same login.
22
30
 
23
31
  ```bash
24
- fcloud setup # prompts for the key; optionally installs the agent skill
32
+ fcloud login [--no-browser] [--agents auto|all|cursor|claude|codex|none]
33
+ fcloud set_token <key> # already have a key (CI, agents, a second machine)
34
+ fcloud setup # save a key you already have; install agent skills
25
35
  fcloud health # verify connectivity
26
36
  ```
27
37
 
28
- `fcloud setup` also installs (by default) the fcloud skill file for supported
29
- coding agents (Cursor, Claude Code, Codex) so an agent can drive fcloud for you.
30
- Use `--agents none` to skip that.
38
+ `fcloud login` installs the fcloud skill file for the coding agents it finds
39
+ (Cursor, Claude Code, Codex — `--agents all` for every one) so an agent can
40
+ drive fcloud for you. `fcloud setup --agents none` skips that.
31
41
 
32
42
  ## Quick start
33
43
 
@@ -107,6 +117,7 @@ Batch
107
117
  fcloud sweeps / fcloud sweep <status|logs|retry|cancel|wait> <name>
108
118
 
109
119
  Setup
120
+ fcloud login [--no-browser] [--agents auto|all|cursor|claude|codex|none]
110
121
  fcloud setup [--token KEY] [--agents all|cursor|claude|codex|none]
111
122
  fcloud set_token <api-key>
112
123
  fcloud --version
@@ -25,6 +25,9 @@ fcloud set_token <api_key> # one-time; saves to ~/.fcloud/token
25
25
  fcloud health # verify connectivity
26
26
  ```
27
27
 
28
+ (A human sets up with `fcloud login`, which signs in through the browser and
29
+ mints a key for the machine; agents use `set_token` with a key they were given.)
30
+
28
31
  Token resolution order:
29
32
 
30
33
  1. `api_key=` parameter to `Client()`
@@ -303,7 +306,8 @@ filesystem later; a **job** for batch/CI-style runs with declared outputs.
303
306
  | Command | Description |
304
307
  | ----------------------------- | ---------------------------------------------------- |
305
308
  | `fcloud create [--sku SKU]` | Create a session (cold — $0, no host, until first used) |
306
- | `fcloud sessions [--all]` | List sessions |
309
+ | `fcloud sessions [--all] [--limit N]` | List sessions with `$/HR` (billed rate), `SPENT` (compute so far) and `AGE` (time on its host) per row and a spending-now total (`--all --json` can be large; bound it with `--limit`) |
310
+ | `fcloud usage [--since 24h]` | What the account has been charged over a window (default 30 days), by resource and session |
307
311
  | `fcloud stop <session-id> [--wait]` | Stop a session now: halt GPU spend (files kept). `--wait` blocks until the workspace manifest is durable |
308
312
 
309
313
 
@@ -1215,6 +1219,8 @@ host (files restored from cloud storage) the next time you use it. You never run
1215
1219
  **Warmth**: `fcloud sessions` reports a session's state — `hot` (running, spending),
1216
1220
  `warm` (idle host held, spending), `stopping` (finishing teardown), `cold`
1217
1221
  (stopped, $0, resumes on use), or `preparing` (coming online).
1222
+ The `$/HR` column is the rate billed on the session's open usage interval while a
1223
+ host is held (0 when cold); `SPENT` is compute charged so far; the footer sums `$/HR`. `fcloud usage` reports what was actually charged.
1218
1224
 
1219
1225
  **Image**: Declarative container spec. Layers are cached by content hash. First run
1220
1226
  on a host triggers a build (30–120s); subsequent runs with the same image are instant.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fcloud-sdk"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Python SDK and CLI for the fcloud GPU compute platform"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -25,6 +25,9 @@ fcloud set_token <api_key> # one-time; saves to ~/.fcloud/token
25
25
  fcloud health # verify connectivity
26
26
  ```
27
27
 
28
+ (A human sets up with `fcloud login`, which signs in through the browser and
29
+ mints a key for the machine; agents use `set_token` with a key they were given.)
30
+
28
31
  Token resolution order:
29
32
 
30
33
  1. `api_key=` parameter to `Client()`
@@ -303,7 +306,8 @@ filesystem later; a **job** for batch/CI-style runs with declared outputs.
303
306
  | Command | Description |
304
307
  | ----------------------------- | ---------------------------------------------------- |
305
308
  | `fcloud create [--sku SKU]` | Create a session (cold — $0, no host, until first used) |
306
- | `fcloud sessions [--all]` | List sessions |
309
+ | `fcloud sessions [--all] [--limit N]` | List sessions with `$/HR` (billed rate), `SPENT` (compute so far) and `AGE` (time on its host) per row and a spending-now total (`--all --json` can be large; bound it with `--limit`) |
310
+ | `fcloud usage [--since 24h]` | What the account has been charged over a window (default 30 days), by resource and session |
307
311
  | `fcloud stop <session-id> [--wait]` | Stop a session now: halt GPU spend (files kept). `--wait` blocks until the workspace manifest is durable |
308
312
 
309
313
 
@@ -1215,6 +1219,8 @@ host (files restored from cloud storage) the next time you use it. You never run
1215
1219
  **Warmth**: `fcloud sessions` reports a session's state — `hot` (running, spending),
1216
1220
  `warm` (idle host held, spending), `stopping` (finishing teardown), `cold`
1217
1221
  (stopped, $0, resumes on use), or `preparing` (coming online).
1222
+ The `$/HR` column is the rate billed on the session's open usage interval while a
1223
+ host is held (0 when cold); `SPENT` is compute charged so far; the footer sums `$/HR`. `fcloud usage` reports what was actually charged.
1218
1224
 
1219
1225
  **Image**: Declarative container spec. Layers are cached by content hash. First run
1220
1226
  on a host triggers a build (30–120s); subsequent runs with the same image are instant.
@@ -119,6 +119,7 @@ from fcloud.cli.sweep import ( # noqa: F401
119
119
  cmd_sweep,
120
120
  cmd_sweeps,
121
121
  )
122
+ from fcloud.cli.usage import cmd_usage # noqa: F401
122
123
  from fcloud.cli.volume import ( # noqa: F401
123
124
  _is_within,
124
125
  _volume_cat,
@@ -593,6 +593,10 @@ class _DirectBridge:
593
593
  api_key=self._cfg.api_key,
594
594
  sku=sku,
595
595
  image_spec=spec_json,
596
+ # The same printer the attach path uses: without it the
597
+ # DirectClient default echoes every host frame, and a cold
598
+ # start dumps a dozen setup_timing lines on the user.
599
+ on_status=_persistent_session_status_printer,
596
600
  )
597
601
  )
598
602
  try:
@@ -1519,6 +1523,7 @@ class _DirectBridge:
1519
1523
  allocation,
1520
1524
  dispatcher_url=self._cfg.dispatcher_url,
1521
1525
  api_key=self._cfg.api_key,
1526
+ on_status=_persistent_session_status_printer,
1522
1527
  )
1523
1528
  )
1524
1529
  try:
@@ -30,6 +30,7 @@ _HELP_GROUPS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
30
30
  ("sessions", "fcloud sessions [SID] [--all] [--limit N]"),
31
31
  ("history", "fcloud history <SID> [--limit N]"),
32
32
  ("stop", "fcloud stop <SID>"),
33
+ ("usage", "fcloud usage [--since DUR]"),
33
34
  )),
34
35
  ("Files", (
35
36
  ("upload", "fcloud upload [--on SID] <local> [remote]"),
@@ -50,7 +51,7 @@ _HELP_GROUPS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
50
51
  ("autoresearch", "fcloud autoresearch init [dir]"),
51
52
  )),
52
53
  ("Setup", (
53
- ("login", "fcloud login"),
54
+ ("login", "fcloud login [--no-browser] [--agents TOOL]"),
54
55
  ("setup", "fcloud setup [--token KEY] [--agents TOOL]"),
55
56
  ("set_token", "fcloud set_token <api-key>"),
56
57
  ("config", "fcloud config <show|get|set|unset> [KEY] [VALUE]"),
@@ -129,8 +130,8 @@ def _print_command_groups() -> None:
129
130
  def print_help():
130
131
  print("fcloud — GPU compute from the command line\n")
131
132
  print("Setup:")
132
- print(" fcloud login Create an account / save your API key")
133
- print(" fcloud setup Save your API key + install agent skills")
133
+ print(" fcloud login Sign in with your browser + install agent skills")
134
+ print(" fcloud setup Save an API key you already have")
134
135
  print(" fcloud health Check connectivity\n")
135
136
  print(_HELP_CONCEPTS + "\n")
136
137
  _print_command_groups()
@@ -2,6 +2,7 @@
2
2
 
3
3
  import sys
4
4
  import time
5
+ from concurrent.futures import ThreadPoolExecutor
5
6
 
6
7
  from fcloud.cli import output, sessions
7
8
  from fcloud.cli.common import (
@@ -223,22 +224,76 @@ def _job_ls(args: list[str]) -> None:
223
224
  reverse=True)
224
225
  if limit_n > 0:
225
226
  jobs = jobs[:limit_n]
227
+ runs = _job_runs(client, [s.session_id for s in jobs])
226
228
  if output._JSON_OUTPUT:
227
229
  output._out({"jobs": [
228
230
  {"session_id": s.session_id, "status": s.status,
229
231
  "state": _session_state(s), "lifecycle": s.lifecycle,
230
- "created_at": s.created_at}
232
+ "created_at": s.created_at,
233
+ "command": runs[s.session_id]["command"],
234
+ "exit_code": runs[s.session_id]["exit_code"]}
231
235
  for s in jobs]})
232
236
  return
233
237
  if not jobs:
234
238
  print("No jobs. Run one with: fcloud job run -- <cmd>"
235
239
  + ("" if show_all else " (--all includes finished jobs)"))
236
240
  return
237
- print(f"{'JOB':<22} {'STATE':<12} {'CREATED'}")
238
- print("-" * 56)
241
+ print(f"{'JOB':<22} {'STATE':<12} {'EXIT':<5} {'CREATED':<21} COMMAND")
242
+ print("-" * 100)
239
243
  for s in jobs:
244
+ run = runs[s.session_id]
245
+ code = run["exit_code"]
240
246
  print(f"{s.session_id:<22} {_session_state(s):<12} "
241
- f"{s.created_at or ''}")
247
+ f"{'' if code is None else code:<5} {s.created_at or '':<21} "
248
+ f"{_command_cell(run['command'])}")
249
+
250
+
251
+ # One index read per listed job — bounded by the rows shown, and fanned out
252
+ # so a 50-row listing costs one round-trip's latency, not fifty.
253
+ _JOB_LS_INDEX_WORKERS = 8
254
+
255
+
256
+ def _job_runs(client, sids: list[str]) -> dict[str, dict]:
257
+ """The command and exit code of each job's run, from its process index.
258
+
259
+ A job is one spawned command, so the index's first record IS the job;
260
+ without it `job ls` is a list of opaque session ids that cannot say
261
+ which row failed or which was which. Missing or unreadable index →
262
+ blanks: a listing must never fail because one job's log is unreadable.
263
+ """
264
+ if not sids:
265
+ return {}
266
+ with ThreadPoolExecutor(max_workers=_JOB_LS_INDEX_WORKERS) as pool:
267
+ return dict(zip(sids, pool.map(
268
+ lambda sid: _job_run_from_index(client, sid), sids), strict=True))
269
+
270
+
271
+ def _job_run_from_index(client, sid: str) -> dict:
272
+ try:
273
+ procs = client.list_session_processes(sid)
274
+ except Exception: # noqa: BLE001 — best-effort enrichment
275
+ procs = []
276
+ first = min(procs, key=lambda p: p.get("started_at_ms") or 0,
277
+ default=None)
278
+ if not first:
279
+ return {"command": None, "exit_code": None}
280
+ command = first.get("command")
281
+ if isinstance(command, list):
282
+ # `job run` wraps a string command in a login shell (see
283
+ # fcloud.job._shape_command); show what the user typed, not the
284
+ # wrapper.
285
+ if len(command) == 3 and command[1] == "-lc" \
286
+ and command[0] in ("/bin/sh", "bash", "sh"):
287
+ command = [command[2]]
288
+ command = " ".join(str(c) for c in command)
289
+ return {"command": command or None, "exit_code": first.get("exit_code")}
290
+
291
+
292
+ def _command_cell(command: str | None, width: int = 40) -> str:
293
+ if not command:
294
+ return ""
295
+ one_line = " ".join(command.split())
296
+ return one_line if len(one_line) <= width else one_line[:width - 1] + "…"
242
297
 
243
298
 
244
299
  def _job_logs(args: list[str]) -> None:
@@ -18,7 +18,12 @@ from fcloud.cli.console import _pid_emitter
18
18
  from fcloud.cli.context import _require_volumes_exist, _resolve_volume_mounts
19
19
  from fcloud.cli.migration import _follow_reconnect, _tail_follow_delta
20
20
  from fcloud.cli.registry import cmd
21
- from fcloud.cli.wait import _print_lost_process_notice, _process_lost
21
+ from fcloud.cli.wait import (
22
+ _confirm_unknown_process,
23
+ _print_lost_process_notice,
24
+ _process_lost,
25
+ _process_unregistered,
26
+ )
22
27
  from fcloud.cli_args import SPECS, split
23
28
  from fcloud.client import Client, SessionNotFoundError
24
29
  from fcloud.types import ProcessStatus
@@ -150,10 +155,38 @@ def _logs_dispatch(client: Client, sid: str, pid: str, opts: dict) -> None:
150
155
  session_status = log_result.session_status
151
156
  if not log_result.is_terminal and not session_status:
152
157
  session_status = _authoritative_session_status(client, sid)
158
+ if _process_unregistered(log_result):
159
+ _refuse_unknown_process(client, sid, pid)
153
160
  _print_log_result(log_result, sid=sid, pid=pid,
154
161
  session_status=session_status)
155
162
 
156
163
 
164
+ # A just-spawned process reaches the durable index a second or two after
165
+ # `spawn` returns; a one-shot read inside that window must not call the
166
+ # pid unknown. Short — `logs` is interactive — and zeroed by tests.
167
+ _LOGS_UNKNOWN_PID_GRACE_S = 4.0
168
+ _LOGS_UNKNOWN_PID_POLL_S = 1.0
169
+
170
+
171
+ def _refuse_unknown_process(client: Client, sid: str, pid: str) -> None:
172
+ """Exit 1 (process_not_found) for a pid the index has no record of.
173
+
174
+ The durable read answers 200 with an empty stream and no status for
175
+ any pid at all, so without this a typo'd id printed as a finished,
176
+ silent process: status "" is not "running", and a poller following
177
+ the docs read it as success. The index is the authority, re-checked
178
+ briefly so a fresh spawn's flush lag isn't mistaken for a bad id."""
179
+ deadline = time.monotonic() + _LOGS_UNKNOWN_PID_GRACE_S
180
+ while True:
181
+ if not _confirm_unknown_process(client, sid, pid):
182
+ # Recorded (or the index read failed): print what we have
183
+ # rather than blame the caller on a transient.
184
+ return
185
+ if time.monotonic() >= deadline:
186
+ _fail_process_not_found(sid, pid)
187
+ time.sleep(_LOGS_UNKNOWN_PID_POLL_S)
188
+
189
+
157
190
  def _parse_logs_args(args: list[str]) -> tuple[str, str, dict]:
158
191
  sid = ""
159
192
  pid = ""
@@ -2,6 +2,7 @@
2
2
 
3
3
  import re
4
4
  import sys
5
+ from concurrent.futures import ThreadPoolExecutor
5
6
  from datetime import datetime, timezone
6
7
 
7
8
  from fcloud.cli import output
@@ -214,6 +215,7 @@ def cmd_sessions(args: list[str]) -> None:
214
215
  truncated = limit_n > 0 and total > limit_n
215
216
  if limit_n > 0:
216
217
  sessions = sessions[:limit_n]
218
+ costs = _session_costs(client, sessions)
217
219
  if output._JSON_OUTPUT:
218
220
  output._out({"sessions": [
219
221
  {"session_id": s.session_id, "project_id": s.project_id,
@@ -232,6 +234,11 @@ def cmd_sessions(args: list[str]) -> None:
232
234
  # False = owner opted out of checkpoint/restore (preemption
233
235
  # rebuilds cold, no region pin).
234
236
  "checkpoint": s.checkpoint,
237
+ # What this row costs right now and for how long it has: the
238
+ # first question in a beta ("what am I paying for?").
239
+ "usd_per_hour": _session_rate(s, costs),
240
+ "spent_usd": _session_spent(s, costs),
241
+ "host_age_seconds": _session_host_age_s(s),
235
242
  "provisioning": s.provisioning}
236
243
  for s in sessions],
237
244
  "total": total, "returned": len(sessions),
@@ -241,16 +248,7 @@ def cmd_sessions(args: list[str]) -> None:
241
248
  print("No active sessions." + ("" if show_all
242
249
  else " Try --all to include cold/failed/dead."))
243
250
  return
244
- print(f"{'SESSION ID':<30} {'HOST':<25} {'SKU':<18} {'STATE'}")
245
- print("-" * 85)
246
- for s in sessions:
247
- state = _session_state(s)
248
- detail = _provisioning_label(s)
249
- if detail:
250
- state = f"{state}: {detail}"
251
- if not s.checkpoint:
252
- state += " [no-checkpoint]"
253
- print(f"{s.session_id:<30} {s.host_id:<25} {s.sku:<18} {state}")
251
+ _print_sessions_table(sessions, costs)
254
252
  if truncated:
255
253
  print(f"\nShowing {len(sessions)} of {total} sessions. "
256
254
  f"Use --limit N (or --limit 0 for all).")
@@ -260,6 +258,122 @@ def cmd_sessions(args: list[str]) -> None:
260
258
  "ended = finished job (never resumes)")
261
259
 
262
260
 
261
+ def _print_sessions_table(sessions: list, costs: dict[str, dict]) -> None:
262
+ # Sized to the data: a fixed HOST width overflowed into SKU on every
263
+ # row for 27-char AWS host ids.
264
+ host_w = max(25, *(len(s.host_id) for s in sessions))
265
+ sku_w = max(18, *(len(s.sku) for s in sessions))
266
+ print(f"{'SESSION ID':<30} {'HOST':<{host_w}} {'SKU':<{sku_w}} "
267
+ f"{'$/HR':>7} {'SPENT':>7} {'AGE':>7} STATE")
268
+ print("-" * (30 + host_w + sku_w + 36))
269
+ for s in sessions:
270
+ state = _session_state(s)
271
+ detail = _provisioning_label(s)
272
+ if detail:
273
+ state = f"{state}: {detail}"
274
+ if not s.checkpoint:
275
+ state += " [no-checkpoint]"
276
+ rate, spent = _session_rate(s, costs), _session_spent(s, costs)
277
+ age = _session_host_age_s(s)
278
+ print(f"{s.session_id:<30} {s.host_id:<{host_w}} {s.sku:<{sku_w}} "
279
+ f"{_fmt_usd(rate):>7} {_fmt_usd(spent):>7} "
280
+ f"{_fmt_age(age) if age is not None else '-':>7} {state}")
281
+ print(_spend_footer(sessions, costs))
282
+
283
+
284
+ # States in which a host is held for the session and the meter runs.
285
+ _SPENDING_STATES = frozenset({"hot", "warm", "stopping"})
286
+ _COST_READ_WORKERS = 8
287
+
288
+
289
+ def _session_costs(client, sessions: list) -> dict[str, dict]:
290
+ """Session id -> its billing record, for the sessions holding a host.
291
+
292
+ The rate a session accrues at is the price snapshotted on its open
293
+ usage interval — the admin price book keyed by SKU, falling back to
294
+ the host's internal cost — so it is read from the session's own cost
295
+ record, never re-derived. One read per spending row (cold rows cost
296
+ $0 and are skipped), fanned out. Best-effort: a billing-less
297
+ environment (501) or a failed read leaves the columns blank; the
298
+ listing itself never fails over pricing."""
299
+ spending = [s for s in sessions if _session_state(s) in _SPENDING_STATES]
300
+ if not spending:
301
+ return {}
302
+
303
+ def _read(sid: str) -> dict | None:
304
+ try:
305
+ return client.session_cost(sid)
306
+ except Exception: # noqa: BLE001 — pricing is decoration on a listing
307
+ return None
308
+ with ThreadPoolExecutor(max_workers=_COST_READ_WORKERS) as pool:
309
+ got = list(pool.map(_read, [s.session_id for s in spending]))
310
+ return {s.session_id: rec for s, rec in zip(spending, got, strict=True)
311
+ if rec is not None}
312
+
313
+
314
+ def _session_rate(s, costs: dict[str, dict]) -> float | None:
315
+ """$/hr the session is accruing now: the open interval's snapshotted
316
+ price while a host is held, 0 for a cold/ended session, None when
317
+ the cost record could not be read."""
318
+ if _session_state(s) not in _SPENDING_STATES:
319
+ return 0.0
320
+ rec = costs.get(s.session_id)
321
+ if rec is None:
322
+ return None
323
+ intervals = rec.get("intervals") or []
324
+ open_ivs = [iv for iv in intervals if iv.get("open")]
325
+ current = (open_ivs or intervals)[-1:] or [{}]
326
+ return (current[0].get("price_usd_micros_per_hour") or 0) / 1_000_000
327
+
328
+
329
+ def _session_spent(s, costs: dict[str, dict]) -> float | None:
330
+ """Compute charged so far on this session, when its record was read."""
331
+ rec = costs.get(s.session_id)
332
+ if rec is None:
333
+ return None
334
+ return (rec.get("compute_usd_micros") or 0) / 1_000_000
335
+
336
+
337
+ def _fmt_usd(amount: float | None) -> str:
338
+ if amount is None:
339
+ return "-"
340
+ # Cents-scale rates (a $0.015/hr CPU box) must not round to 0.00/0.02.
341
+ return f"{amount:.2f}" if amount == 0 or amount >= 0.1 else f"{amount:.4f}"
342
+
343
+
344
+ def _session_host_age_s(s) -> int | None:
345
+ """Seconds the session has held its current host, or None off-host."""
346
+ if _session_state(s) not in _SPENDING_STATES:
347
+ return None
348
+ since = _parse_api_timestamp(s.allocated_at or s.created_at)
349
+ if since is None:
350
+ return None
351
+ return max(0, int((datetime.now(timezone.utc) - since).total_seconds()))
352
+
353
+
354
+ def _fmt_age(seconds: int) -> str:
355
+ if seconds < 60:
356
+ return f"{seconds}s"
357
+ if seconds < 3600:
358
+ return f"{seconds // 60}m"
359
+ if seconds < 86400:
360
+ return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
361
+ return f"{seconds // 86400}d{(seconds % 86400) // 3600}h"
362
+
363
+
364
+ def _spend_footer(sessions: list, costs: dict[str, dict]) -> str:
365
+ spending = [s for s in sessions if _session_state(s) in _SPENDING_STATES]
366
+ if not spending:
367
+ return "\nSpending now: $0/hr (no session holds a host)"
368
+ rates = [_session_rate(s, costs) for s in spending]
369
+ known = [r for r in rates if r is not None]
370
+ line = (f"\nSpending now: ${_fmt_usd(sum(known))}/hr across "
371
+ f"{len(spending)} session(s) holding a host")
372
+ if len(known) < len(spending):
373
+ line += f" ({len(spending) - len(known)} with no billing record)"
374
+ return line + " — stop one with: fcloud stop <session-id>"
375
+
376
+
263
377
  @cmd("history", "fcloud history <session-id> [--limit N]",
264
378
  "Show a session's lifecycle timeline")
265
379
  def cmd_history(args: list[str]) -> None: