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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ go install github.com/eyra/flux-cli@latest
### Agent skill

The binary carries the flux skill for AI coding agents. Install it for the
agents you use, and rerun after upgrading flux:
agents you use:

```bash
flux install claude # Claude Code: ~/.claude/skills/flux (CLAUDE_CONFIG_DIR replaces ~/.claude)
Expand All @@ -30,6 +30,11 @@ available to omp too. A `SKILL.md` that differs from the installed version is
first backed up to `SKILL.md.bak-<time>`. A symlinked skill folder or file is
left alone, and the command fails.

`flux update` refreshes the skill for every agent that has one, from the
updated binary, even when the binary is already up to date; `--no-skill` skips
this. A symlinked skill is skipped. Rerun `flux install` after `brew upgrade`
or `go install`.

## Usage

### List issues
Expand Down
114 changes: 70 additions & 44 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,30 +26,75 @@ its personal skills:
omp also reads ~/.agents/skills, so "flux install codex" makes the skill
available to omp too.

Rerun after upgrading flux to update the skill. A SKILL.md that differs from
this version is first backed up to SKILL.md.bak-<time>.`,
"flux update" refreshes the skills you installed; rerun after "brew upgrade" or
"go install". A SKILL.md that differs from this version is first backed up to
SKILL.md.bak-<time>.`,
}

// installTarget returns the command that installs the skill into
// <root>/skills/flux, where root is $envVar or else ~/defaultRoot.
func installTarget(name, short, long, envVar, defaultRoot string) *cobra.Command {
// agent is a coding agent that reads personal skills from <root>/skills, where
// root is $envVar or else ~/defaultRoot.
type agent struct {
name, envVar, defaultRoot, short, long string
}

var agents = []agent{
{"claude", "CLAUDE_CONFIG_DIR", ".claude", "Install the flux skill for Claude Code",
`Install the flux skill into ~/.claude/skills/flux, where Claude Code reads
personal skills. CLAUDE_CONFIG_DIR replaces ~/.claude.`},
{"omp", "PI_CODING_AGENT_DIR", filepath.Join(".omp", "agent"), "Install the flux skill for omp (oh-my-pi)",
`Install the flux skill into ~/.omp/agent/skills/flux, omp's own skills
folder, which takes priority over every other skill location.
PI_CODING_AGENT_DIR replaces ~/.omp/agent.

"flux install codex" already makes the skill available to omp, through
~/.agents/skills; use this command to give omp its own copy.`},
{"codex", "", ".agents", "Install the flux skill for Codex",
`Install the flux skill into ~/.agents/skills/flux, where Codex reads
personal skills. omp and other agents that read ~/.agents/skills pick it up
too.`},
}

func (a agent) root() (string, error) {
if a.envVar != "" {
if root := os.Getenv(a.envVar); root != "" {
return root, nil
}
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, a.defaultRoot), nil
}

// skillPaths returns the skill folder and SKILL.md under root.
func skillPaths(root string) (dir, path string) {
dir = filepath.Join(root, "skills", "flux")
return dir, filepath.Join(dir, "SKILL.md")
}

// skillSymlink returns the first of dir and path that is a symlink, or "".
func skillSymlink(dir, path string) string {
for _, p := range []string{dir, path} {
if info, err := os.Lstat(p); err == nil && info.Mode()&fs.ModeSymlink != 0 {
return p
}
}
return ""
}

// installTarget returns the command that installs the skill for a.
func installTarget(a agent) *cobra.Command {
return &cobra.Command{
Use: name,
Short: short,
Long: long,
Use: a.name,
Short: a.short,
Long: a.long,
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
root := ""
if envVar != "" {
root = os.Getenv(envVar)
}
if root == "" {
home, err := os.UserHomeDir()
if err != nil {
return err
}
root = filepath.Join(home, defaultRoot)
root, err := a.root()
if err != nil {
return err
}
path, backup, err := installSkill(root, skill.Markdown)
if err != nil {
Expand Down Expand Up @@ -78,13 +123,10 @@ func installTarget(name, short, long, envVar, defaultRoot string) *cobra.Command
// symlinked skill folder or file is refused, so a link to a checkout of this
// repository is never overwritten.
func installSkill(root string, data []byte) (path, backup string, err error) {
dir := filepath.Join(root, "skills", "flux")
path = filepath.Join(dir, "SKILL.md")
for _, p := range []string{dir, path} {
if info, err := os.Lstat(p); err == nil && info.Mode()&fs.ModeSymlink != 0 {
target, _ := os.Readlink(p)
return "", "", fmt.Errorf("%s is a symlink to %s; remove it to install", p, target)
}
dir, path := skillPaths(root)
if link := skillSymlink(dir, path); link != "" {
target, _ := os.Readlink(link)
return "", "", fmt.Errorf("%s is a symlink to %s; remove it to install", link, target)
}
existing, err := os.ReadFile(path)
switch {
Expand All @@ -108,24 +150,8 @@ func installSkill(root string, data []byte) (path, backup string, err error) {
}

func init() {
installCmd.AddCommand(
installTarget("claude", "Install the flux skill for Claude Code",
`Install the flux skill into ~/.claude/skills/flux, where Claude Code reads
personal skills. CLAUDE_CONFIG_DIR replaces ~/.claude.`,
"CLAUDE_CONFIG_DIR", ".claude"),
installTarget("omp", "Install the flux skill for omp (oh-my-pi)",
`Install the flux skill into ~/.omp/agent/skills/flux, omp's own skills
folder, which takes priority over every other skill location.
PI_CODING_AGENT_DIR replaces ~/.omp/agent.

"flux install codex" already makes the skill available to omp, through
~/.agents/skills; use this command to give omp its own copy.`,
"PI_CODING_AGENT_DIR", filepath.Join(".omp", "agent")),
installTarget("codex", "Install the flux skill for Codex",
`Install the flux skill into ~/.agents/skills/flux, where Codex reads
personal skills. omp and other agents that read ~/.agents/skills pick it up
too.`,
"", ".agents"),
)
for _, a := range agents {
installCmd.AddCommand(installTarget(a))
}
rootCmd.AddCommand(installCmd)
}
57 changes: 47 additions & 10 deletions cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
Expand All @@ -19,6 +20,8 @@ const githubRepo = "eyra/flux-cli"

var currentVersion = "dev"

var noSkill bool

func SetVersion(v string) {
currentVersion = v
}
Expand All @@ -35,13 +38,6 @@ var updateCmd = &cobra.Command{
return fmt.Errorf("could not fetch latest release: %w", err)
}

if latest == currentVersion {
fmt.Printf("Already up to date (%s)\n", currentVersion)
return nil
}

fmt.Printf("Updating to %s...\n", latest)

execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("could not determine executable path: %w", err)
Expand All @@ -51,11 +47,19 @@ var updateCmd = &cobra.Command{
return fmt.Errorf("could not resolve symlinks: %w", err)
}

if err := downloadAndReplace(downloadURL, execPath); err != nil {
return fmt.Errorf("update failed: %w", err)
if latest == currentVersion {
fmt.Printf("Already up to date (%s)\n", currentVersion)
} else {
fmt.Printf("Updating to %s...\n", latest)
if err := downloadAndReplace(downloadURL, execPath); err != nil {
return fmt.Errorf("update failed: %w", err)
}
fmt.Printf("✓ Updated to %s\n", latest)
}

fmt.Printf("✓ Updated to %s\n", latest)
if !noSkill {
refreshSkills(cmd.OutOrStdout(), cmd.ErrOrStderr(), execPath)
}
return nil
},
}
Expand Down Expand Up @@ -99,6 +103,38 @@ func fetchLatestRelease() (tag, downloadURL string, err error) {
return "", "", fmt.Errorf("no release asset found for %s/%s", os_, arch)
}

// refreshSkills runs "<flux> install <agent>" for every agent that already has
// the skill, so the skill comes from the binary at flux, not this process.
// Symlinked skills are skipped; a failed install is only a warning, because
// the binary itself was updated.
func refreshSkills(stdout, stderr io.Writer, flux string) {
found := false
for _, a := range agents {
root, err := a.root()
if err != nil {
continue
}
dir, path := skillPaths(root)
if link := skillSymlink(dir, path); link != "" {
fmt.Fprintf(stdout, "Skipped %s: %s is a symlink\n", a.name, link)
found = true
continue
}
if _, err := os.Lstat(path); err != nil {
continue
}
found = true
install := exec.Command(flux, "install", a.name)
install.Stdout, install.Stderr = stdout, stderr
if err := install.Run(); err != nil {
fmt.Fprintf(stderr, "Warning: the flux skill for %s was not updated (%v)\n", a.name, err)
}
}
if !found {
fmt.Fprintln(stdout, "No flux skill installed; run flux install claude, omp or codex")
}
}

func downloadAndReplace(url, dest string) error {
resp, err := http.Get(url)
if err != nil {
Expand Down Expand Up @@ -148,5 +184,6 @@ func downloadAndReplace(url, dest string) error {
}

func init() {
updateCmd.Flags().BoolVar(&noSkill, "no-skill", false, "Don't refresh the installed flux skills")
rootCmd.AddCommand(updateCmd, versionCmd)
}
112 changes: 112 additions & 0 deletions cmd/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package cmd

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)

func TestRefreshSkills(t *testing.T) {
const hint = "No flux skill installed; run flux install claude, omp or codex\n"
for _, tc := range []struct {
name string
state map[string]string // agent → skill, folder, linkdir or linkfile
fail string // agent whose install exits 1
calls string
out string // {claude}, {omp} and {codex} stand for the agent roots
warn string
}{
{name: "nothing installed", out: hint},
{name: "claude only", state: map[string]string{"claude": "skill"},
calls: "install claude\n"},
{name: "all three", state: map[string]string{"claude": "skill", "omp": "skill", "codex": "skill"},
calls: "install claude\ninstall omp\ninstall codex\n"},
{name: "folder without SKILL.md", state: map[string]string{"claude": "folder"}, out: hint},
{name: "symlinked folder", state: map[string]string{"claude": "skill", "omp": "linkdir"},
calls: "install claude\n", out: "Skipped omp: {omp}/skills/flux is a symlink\n"},
{name: "symlinked SKILL.md only", state: map[string]string{"codex": "linkfile"},
out: "Skipped codex: {codex}/skills/flux/SKILL.md is a symlink\n"},
{name: "one install fails", state: map[string]string{"claude": "skill", "omp": "skill", "codex": "skill"},
fail: "omp", calls: "install claude\ninstall omp\ninstall codex\n",
warn: "Warning: the flux skill for omp was not updated (exit status 1)\n"},
} {
t.Run(tc.name, func(t *testing.T) {
isolateCommand(t)
bin := t.TempDir()
log := filepath.Join(bin, "calls")
flux := filepath.Join(bin, "flux")
script := "#!/bin/sh\necho \"$*\" >> \"$FAKE_LOG\"\n[ \"$2\" != \"$FAKE_FAIL\" ]\n"
if err := os.WriteFile(flux, []byte(script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("FAKE_LOG", log)
t.Setenv("FAKE_FAIL", tc.fail)
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
t.Setenv("PI_CODING_AGENT_DIR", t.TempDir())

roots := []string{}
for _, a := range agents {
root, err := a.root()
if err != nil {
t.Fatal(err)
}
roots = append(roots, "{"+a.name+"}", root)
dir, path := skillPaths(root)
switch tc.state[a.name] {
case "skill":
mustWrite(t, path)
case "folder":
mkdir(t, dir)
case "linkdir":
checkout := t.TempDir()
mustWrite(t, filepath.Join(checkout, "SKILL.md"))
mkdir(t, filepath.Dir(dir))
symlink(t, checkout, dir)
case "linkfile":
target := filepath.Join(t.TempDir(), "SKILL.md")
mustWrite(t, target)
mkdir(t, dir)
symlink(t, target, path)
}
}

var stdout, stderr bytes.Buffer
refreshSkills(&stdout, &stderr, flux)

calls, _ := os.ReadFile(log)
if string(calls) != tc.calls {
t.Errorf("calls %q, want %q", calls, tc.calls)
}
if want := strings.NewReplacer(roots...).Replace(tc.out); stdout.String() != want {
t.Errorf("stdout %q, want %q", stdout.String(), want)
}
if stderr.String() != tc.warn {
t.Errorf("stderr %q, want %q", stderr.String(), tc.warn)
}
})
}
}

func mustWrite(t *testing.T, path string) {
t.Helper()
mkdir(t, filepath.Dir(path))
if err := os.WriteFile(path, []byte("skill"), 0644); err != nil {
t.Fatal(err)
}
}

func mkdir(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
}

func symlink(t *testing.T, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
}
1 change: 1 addition & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Replace `<key>` with the resolved production project key in every example. Scene
| Render diagram (inline) | `flux diagrams render --mermaid "graph TD; A-->B" --env prod --project <key> --json` |
| AppSignal apps | `flux appsignal apps --env prod --project <key> --json` |
| AppSignal incidents | `flux appsignal incidents list --app <app> --env prod --project <key> --json` |
| Update CLI and skill | `flux update [--no-skill]` |

## Environment & Project Selection

Expand Down
Loading