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
4 changes: 4 additions & 0 deletions .agents/skills/cfs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ Treat the cfs CLI as the source of truth. Do not inspect or edit CF configuratio
Never select a context through `cf target`, a manual `CF_HOME`, `CFS_DISABLE`, or a direct official-CF binary path. Keep shared diagnostics both JSON-formatted and redacted.

Selecting a context grants no authority to log in, deploy, import, create or remove contexts, change targets, or perform any other mutation. Run such commands only when the user has authorized that specific operation. If inspection shows the selected context is unavailable or not logged in, report that state and ask before changing it.

When asked whether cfs itself has an update, run `cfs update --json`. This check
is read-only. Report its `status` and `latest_version`; do not execute any
returned update command unless the user separately authorizes installation.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ contract may still change while cfs is pre-1.0.

## [Unreleased]

### Added

- Added `cfs update` with agent-safe JSON output and cached, opt-out update
notices that never run from the transparent `cf` shim.

## [0.2.0] - 2026-09-23

### Added
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,30 @@ Another project or Git worktree receives a separate context automatically.
Terminals and agents in the same worktree intentionally share its default
context.

## Updating

Check for a newer published release without changing the installed binary:

```sh
cfs update
```

For scripts and coding agents, use `cfs update --json`. When an update is
available, follow the printed command or update through the same package manager
used for installation. A Go installation can be updated with:

```sh
go install github.com/zongqichen/cfs/cmd/cfs@latest
cfs setup
cfs doctor
```

Successful interactive `cfs` control commands check at most once every 24 hours
and show one notice per new version. Checks never run from the transparent `cf`
shim, JSON output, CI, or non-interactive processes. Set
`CFS_NO_UPDATE_CHECK=1` to disable notices. Version checks contact only the public
GitHub Releases API and send no Cloud Foundry or workspace data.

## Multiple targets in one project

The normal `cf` command always uses the workspace's `default` context. Create a
Expand Down Expand Up @@ -141,6 +165,7 @@ cfs reset Move this workspace's state to trash
cfs gc Find stale workspace state
cfs uninstall Remove the shim without deleting state
cfs version Print version information
cfs update Check for a newer published release
cfs help [command] Show help
```

Expand Down
6 changes: 6 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ The project does not collect telemetry and must never log CF credentials or full
command lines. CF plugins are executable code and should be installed only from
trusted sources.

`cfs update` and the cached interactive update notice contact only the public
GitHub Releases API. Requests contain no Cloud Foundry target, credential,
workspace, context, or command data. The cache contains only public release
versions and timestamps. Set `CFS_NO_UPDATE_CHECK=1` to disable passive checks.
Update discovery never downloads or executes a replacement binary.

Managed `CF_HOME` directories contain the same authentication material as an
ordinary official CF CLI home. Owner-only filesystem permissions protect them
from other local users, but the files are not encrypted by `cfs`; backups and
Expand Down
2 changes: 2 additions & 0 deletions cmd/cfs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"runtime/debug"

"github.com/zongqichen/cfs/internal/app"
"github.com/zongqichen/cfs/internal/updatecheck"
)

var version = "dev"
Expand All @@ -21,6 +22,7 @@ func main() {
Version: resolvedVersion,
Commit: resolvedCommit,
BuildDate: resolvedBuildDate,
Updates: updatecheck.New(updatecheck.Options{}),
}))
}

Expand Down
23 changes: 21 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ Cloud Foundry deployments.
overwritten, renamed, or deleted.
10. **No telemetry by default.** The product does not collect command
arguments, target names, credentials, or usage data.
11. **Updates stay off the data path.** Release checks never run from the
transparent `cf` shim and never change the installed executable.

## 4. User experience

Expand Down Expand Up @@ -191,16 +193,32 @@ The initial control interface is deliberately small:
| `cfs gc` | Report orphaned workspace state; deletion requires `--apply`. |
| `cfs uninstall` | Remove the shim and PATH integration, preserving state. |
| `cfs version` | Print the `cfs` version and build information. |
| `cfs update` | Check published releases and print safe update guidance. |
| `cfs help [command]` | Show global or command-specific help. |

Global conventions:

- `--json` produces stable JSON for `status`, `context list`, `context status`,
`doctor`, and `gc`.
`doctor`, `gc`, and `update`.
- Errors use the form `cfs: <message>`.
- Interactive prompts are never used when standard input is not a terminal.
- Destructive commands require an explicit flag in non-interactive mode.

### 5.1 Update discovery

`cfs update` compares the running version with published semantic-version tags
from the public GitHub Releases API. Drafts and malformed tags are ignored;
pre-1.0 GitHub pre-releases are included. The command is read-only and reports
the release URL plus commands for the existing Go installation path. Package
managers remain responsible for replacing the executable.

Successful interactive control commands may perform the same check at most once
per 24 hours and show one notice for each new version. The cache contains only
public version metadata. Passive checks are disabled for the `cf` shim, JSON
output, CI, non-terminal output, development builds, and when
`CFS_NO_UPDATE_CHECK=1` is set. Network and cache failures never change the
original command's output or exit status.

Context names contain 1–63 lowercase ASCII letters or digits, with dots,
hyphens, and underscores permitted internally. Unknown or invalid names fail
closed. Credentials are never copied implicitly.
Expand Down Expand Up @@ -487,6 +505,7 @@ internal/
lock/ platform-specific process locks
runner/ child process and signal forwarding
install/ shim and shell PATH integration
updatecheck/ published-release lookup and notification cache
test/
e2e/ official CLI and mock CF/UAA lifecycle tests
```
Expand All @@ -506,7 +525,7 @@ The first usable release includes:
- Per-context exclusive locking.
- Workspace-local named contexts with explicit per-command selection.
- `setup`, `status`, `context`, `import`, `doctor`, `reset`, `gc`, `uninstall`,
and `version`.
`version`, and read-only update discovery.
- Human-readable English output and stable JSON diagnostics.
- Linux and macOS support on amd64 and arm64.
- Automated tests against supported official CF CLI versions.
Expand Down
4 changes: 4 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ routes the command through the exact named context. It uses the caller's Codex
authentication and provider configuration, runs with a temporary home and
workspace, and deletes the fixture repository afterward.

Update discovery tests use local HTTP servers and temporary caches. Unit and CI
tests never query GitHub Releases; a live `cfs update` check is a manual release
validation step.

Run the full protocol test with an official CF CLI binary:

```sh
Expand Down
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@ module github.com/zongqichen/cfs

go 1.26.8

require golang.org/x/sys v0.48.0
require (
golang.org/x/mod v0.41.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
32 changes: 25 additions & 7 deletions internal/app/app.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app

import (
"context"
"errors"
"fmt"
"io"
Expand All @@ -13,6 +14,8 @@ import (
"github.com/zongqichen/cfs/internal/executable"
"github.com/zongqichen/cfs/internal/lock"
"github.com/zongqichen/cfs/internal/runner"
"github.com/zongqichen/cfs/internal/updatecheck"
"golang.org/x/term"
)

const (
Expand All @@ -24,13 +27,20 @@ const (
)

type Options struct {
Args []string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Version string
Commit string
BuildDate string
Args []string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Version string
Commit string
BuildDate string
Updates UpdateService
IsInteractive func(io.Writer) bool
}

type UpdateService interface {
Check(context.Context, string) (updatecheck.Result, error)
Notification(context.Context, string) (updatecheck.Result, bool, error)
}

func Run(options Options) int {
Expand Down Expand Up @@ -169,9 +179,17 @@ func withDefaultStreams(options Options) Options {
if options.Stderr == nil {
options.Stderr = os.Stderr
}
if options.IsInteractive == nil {
options.IsInteractive = isOutputTerminal
}
return options
}

func isOutputTerminal(writer io.Writer) bool {
file, ok := writer.(*os.File)
return ok && term.IsTerminal(int(file.Fd()))
}

func fprintf(writer io.Writer, format string, values ...any) {
_, _ = fmt.Fprintf(writer, format, values...)
}
90 changes: 90 additions & 0 deletions internal/app/command_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package app

import (
"context"
"strings"

"github.com/zongqichen/cfs/internal/envvar"
"github.com/zongqichen/cfs/internal/updatecheck"
)

func commandUpdate(options Options, args []string) int {
flags := newFlagSet("update", options.Stderr)
jsonOutput := flags.Bool("json", false, "print JSON output")
if code, ok := parseFlagSet(flags, args); !ok {
return code
}
if flags.NArg() != 0 {
fprintf(options.Stderr, "cfs: update does not accept positional arguments\n")
return exitUsage
}
if options.Updates == nil {
fprintf(options.Stderr, "cfs: update checking is unavailable in this build\n")
return exitUnavailable
}

result, err := options.Updates.Check(context.Background(), options.Version)
if err != nil {
fprintf(options.Stderr, "cfs: check for updates: %v\n", err)
return exitUnavailable
}
if *jsonOutput {
return writeJSON(options, result)
}
printUpdateResult(options, result)
return exitOK
}

func printUpdateResult(options Options, result updatecheck.Result) {
switch result.Status {
case updatecheck.StatusUpdateAvailable:
fprintf(options.Stdout, "Update available: %s -> %s\n", result.CurrentVersion, result.LatestVersion)
fprintf(options.Stdout, "Release: %s\n", result.ReleaseURL)
fprintf(options.Stdout, "Update with the same installation method, or run:\n")
for _, command := range result.Commands {
fprintf(options.Stdout, " %s\n", command)
}
case updatecheck.StatusUpToDate:
fprintf(options.Stdout, "cfs %s is up to date.\n", result.CurrentVersion)
case updatecheck.StatusAhead:
fprintf(options.Stdout, "cfs %s is newer than the latest published release (%s).\n", result.CurrentVersion, result.LatestVersion)
default:
fprintf(options.Stdout, "Current build %s cannot be compared with published releases.\n", result.CurrentVersion)
fprintf(options.Stdout, "Latest release: %s\n", result.LatestVersion)
fprintf(options.Stdout, "Release: %s\n", result.ReleaseURL)
}
}

func maybeNotifyUpdate(options Options, command commandSpec, args []string) {
if !shouldNotifyUpdate(options, command, args) {
return
}
result, notify, err := options.Updates.Notification(context.Background(), options.Version)
if err != nil || !notify {
return
}
fprintf(options.Stderr, "cfs: update available: %s -> %s; run 'cfs update'\n", result.CurrentVersion, result.LatestVersion)
}

func shouldNotifyUpdate(options Options, command commandSpec, args []string) bool {
if options.Updates == nil || isHelpRequest(args) || hasJSONFlag(args) {
return false
}
switch command.name {
case "help", "uninstall", "update":
return false
}
if envTrue(envvar.NoUpdateCheck) || envTrue(envvar.CI) {
return false
}
return options.IsInteractive != nil && options.IsInteractive(options.Stderr)
}

func hasJSONFlag(args []string) bool {
for _, argument := range args {
if argument == "--json" || argument == "-json" || strings.HasPrefix(argument, "--json=") || strings.HasPrefix(argument, "-json=") {
return true
}
}
return false
}
Loading
Loading