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
6 changes: 6 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ homebrew_casks:
git:
url: git@github.com:eyra/homebrew-tap.git
private_key: '{{ index .Env "HOMEBREW_TAP_DEPLOY_KEY" }}'
caveats: |
Install the flux skill for your AI coding agents with one or more of:
flux install claude
flux install omp
flux install codex
Rerun them after `brew upgrade flux-cli` to update the skill.
hooks:
post:
install: |
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ Or with Go:
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:

```bash
flux install claude # Claude Code: ~/.claude/skills/flux (CLAUDE_CONFIG_DIR replaces ~/.claude)
flux install omp # omp: ~/.omp/agent/skills/flux (PI_CODING_AGENT_DIR replaces ~/.omp/agent)
flux install codex # Codex: ~/.agents/skills/flux
```

omp also reads `~/.agents/skills`, so `flux install codex` makes the skill
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.

## Usage

### List issues
Expand Down
131 changes: 131 additions & 0 deletions cmd/install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package cmd

import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"

"github.com/eyra/flux-cli/skill"
"github.com/spf13/cobra"
)

var installCmd = &cobra.Command{
Use: "install",
Short: "Install the flux skill for an AI coding agent",
Long: `Install the flux agent skill, embedded in this binary, where an agent reads
its personal skills:

claude ~/.claude/skills/flux (CLAUDE_CONFIG_DIR replaces ~/.claude)
omp ~/.omp/agent/skills/flux (PI_CODING_AGENT_DIR replaces ~/.omp/agent)
codex ~/.agents/skills/flux

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>.`,
}

// 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 {
return &cobra.Command{
Use: name,
Short: short,
Long: 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)
}
path, backup, err := installSkill(root, skill.Markdown)
if err != nil {
return err
}
if jsonFlag {
fields := []string{"path", path}
if backup != "" {
fields = append(fields, "backup", backup)
}
printOK(fields...)
return nil
}
out := cmd.OutOrStdout()
if backup != "" {
fmt.Fprintf(out, "Backed up previous skill to %s\n", backup)
}
fmt.Fprintf(out, "Installed flux skill to %s\n", path)
return nil
},
}
}

// installSkill writes data to <root>/skills/flux/SKILL.md. An identical file is
// left alone; a different one is renamed to SKILL.md.bak-<time> first. A
// 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)
}
}
existing, err := os.ReadFile(path)
switch {
case err == nil && bytes.Equal(existing, data):
return path, "", nil
case err == nil:
backup = path + ".bak-" + time.Now().Format("20060102-150405")
if err := os.Rename(path, backup); err != nil {
return "", "", err
}
case !errors.Is(err, fs.ErrNotExist):
return "", "", err
}
if err := os.MkdirAll(dir, 0755); err != nil {
return "", "", err
}
if err := os.WriteFile(path, data, 0644); err != nil {
return "", "", err
}
return path, backup, nil
}

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"),
)
rootCmd.AddCommand(installCmd)
}
126 changes: 126 additions & 0 deletions cmd/install_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package cmd

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

"github.com/spf13/cobra"
)

func TestInstallTargets(t *testing.T) {
want, err := os.ReadFile(filepath.Join("..", "skill", "SKILL.md"))
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
target, envVar, defaultDir string
}{
{"claude", "CLAUDE_CONFIG_DIR", ".claude"},
{"omp", "PI_CODING_AGENT_DIR", ".omp/agent"},
{"codex", "", ".agents"},
} {
for _, override := range []bool{false, true} {
if override && tc.envVar == "" {
continue
}
name := tc.target
if override {
name += " with " + tc.envVar
}
t.Run(name, func(t *testing.T) {
isolateCommand(t)
t.Setenv("CLAUDE_CONFIG_DIR", "")
t.Setenv("PI_CODING_AGENT_DIR", "")
root := filepath.Join(os.Getenv("HOME"), tc.defaultDir)
if override {
root = t.TempDir()
t.Setenv(tc.envVar, root)
}
command, _, err := installCmd.Find([]string{tc.target})
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
out := &cobra.Command{}
out.SetOut(&output)
if err := command.RunE(out, nil); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "skills", "flux", "SKILL.md")
got, err := os.ReadFile(path)
if err != nil || !bytes.Equal(got, want) {
t.Fatalf("%s does not hold skill/SKILL.md: %v", path, err)
}
if output.String() != "Installed flux skill to "+path+"\n" {
t.Fatalf("unexpected output: %q", output.String())
}
})
}
}
}

func TestInstallSkillBacksUpLocalEdits(t *testing.T) {
root := t.TempDir()
path, backup, err := installSkill(root, []byte("v1"))
if err != nil || backup != "" {
t.Fatalf("fresh install: backup %q, err %v", backup, err)
}
if _, backup, err = installSkill(root, []byte("v1")); err != nil || backup != "" {
t.Fatalf("identical reinstall: backup %q, err %v", backup, err)
}
if err := os.WriteFile(path, []byte("v1 with local edits"), 0644); err != nil {
t.Fatal(err)
}
if _, backup, err = installSkill(root, []byte("v2")); err != nil || backup == "" {
t.Fatalf("changed reinstall: backup %q, err %v", backup, err)
}
if got, _ := os.ReadFile(backup); string(got) != "v1 with local edits" {
t.Fatalf("backup holds %q", got)
}
if got, _ := os.ReadFile(path); string(got) != "v2" {
t.Fatalf("skill holds %q", got)
}
backups, _ := filepath.Glob(path + ".bak-*")
if len(backups) != 1 {
t.Fatalf("expected one backup, found %v", backups)
}
}

func TestInstallSkillRefusesSymlinks(t *testing.T) {
for _, link := range []string{"folder", "file"} {
t.Run(link, func(t *testing.T) {
root, checkout := t.TempDir(), t.TempDir()
repoSkill := filepath.Join(checkout, "SKILL.md")
if err := os.WriteFile(repoSkill, []byte("checkout"), 0644); err != nil {
t.Fatal(err)
}
dir := filepath.Join(root, "skills", "flux")
if link == "folder" {
if err := os.MkdirAll(filepath.Dir(dir), 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(checkout, dir); err != nil {
t.Fatal(err)
}
} else {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(repoSkill, filepath.Join(dir, "SKILL.md")); err != nil {
t.Fatal(err)
}
}
_, _, err := installSkill(root, []byte("release"))
if err == nil || !strings.Contains(err.Error(), "is a symlink to") {
t.Fatalf("expected a symlink error, got %v", err)
}
entries, _ := os.ReadDir(checkout)
if got, _ := os.ReadFile(repoSkill); string(got) != "checkout" || len(entries) != 1 {
t.Fatalf("checkout was touched: %q, %d entries", got, len(entries))
}
})
}
}
Loading
Loading