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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable user-facing changes to `sonacli` are documented in this file.
## [Unreleased]
- Nothing yet.

## [v0.1.0] - 2026-03-27
- Added a `sonacli update` command that downloads a release archive, verifies `checksums.txt`, and replaces the current binary in place.
- Added a `sonacli star` command that opens the project GitHub repository in the default browser.

## [v0.1.0-rc.3] - 2026-03-27
- Added a root `install.sh` and simplified `README.md` so users can install `sonacli` directly from `curl` or `wget`.

Expand All @@ -34,7 +38,8 @@ All notable user-facing changes to `sonacli` are documented in this file.
macOS.
- Security policy for vulnerability reporting and supported version guidance.

[Unreleased]: https://github.com/mshddev/sonacli/compare/v0.1.0-rc.3...HEAD
[Unreleased]: https://github.com/mshddev/sonacli/compare/v0.1.0...HEAD
[v0.1.0]: https://github.com/mshddev/sonacli/releases/tag/v0.1.0
[v0.1.0-rc.3]: https://github.com/mshddev/sonacli/releases/tag/v0.1.0-rc.3
[v0.1.0-rc.2]: https://github.com/mshddev/sonacli/releases/tag/v0.1.0-rc.2
[v0.1.0-rc.1]: https://github.com/mshddev/sonacli/releases/tag/v0.1.0-rc.1
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ curl -fsSL https://raw.githubusercontent.com/mshddev/sonacli/main/install.sh | s

The installer downloads the matching GitHub release archive for your OS and CPU, verifies `checksums.txt`, installs `sonacli`, and prints a `PATH` hint when needed.

After installing from a release, update the binary in place with:

```sh
sonacli update
sonacli update --version v0.1.0-rc.3
```

### Build from source

```sh
Expand Down
29 changes: 29 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,35 @@ sonacli skill uninstall --codex
| `--claude` | Remove from Claude Code |
| `--codex` | Remove from Codex |

## Repository

### `sonacli star`

Open the `sonacli` GitHub repository in your default browser so you can star it manually.

```sh
sonacli star
```

On macOS this uses `open`. On Linux this uses `xdg-open`.

## Updating

### `sonacli update`

Download the matching GitHub release archive for the current OS and CPU, verify `checksums.txt`, and replace the running `sonacli` executable in place.

```sh
sonacli update
sonacli update --version v0.1.0-rc.3
```

If the requested tag matches the current build version, `sonacli` reports that it is already installed and exits without replacing the binary.

| Flag | Description |
|------|-------------|
| `--version` | Install a specific release tag instead of the latest release |

## Versioning

### `sonacli version`
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func NewRootCmd(stdout, stderr io.Writer) *cobra.Command {
})
cmd.CompletionOptions.DisableDefaultCmd = true
cmd.InitDefaultHelpFlag()
cmd.AddCommand(NewAuthCmd(), NewIssueCmd(), NewProjectCmd(), NewSkillCmd(), NewVersionCmd())
cmd.AddCommand(NewAuthCmd(), NewIssueCmd(), NewProjectCmd(), NewSkillCmd(), NewStarCmd(), NewUpdateCmd(), NewVersionCmd())

return cmd
}
Expand Down
8 changes: 8 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ func assertRootHelp(t *testing.T, output string) {
t.Fatalf("expected skill command to be listed, got %q", output)
}

if !strings.Contains(output, "star") || !strings.Contains(output, "Star the sonacli GitHub repository") {
t.Fatalf("expected star command to be listed, got %q", output)
}

if !strings.Contains(output, "update") || !strings.Contains(output, "Update sonacli to the latest version") {
t.Fatalf("expected update command to be listed, got %q", output)
}

if !strings.Contains(output, "version") {
t.Fatalf("expected version command to be listed, got %q", output)
}
Expand Down
77 changes: 77 additions & 0 deletions internal/cli/star.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cli

import (
"context"
"fmt"
"os/exec"
"runtime"
"strings"

"github.com/spf13/cobra"
)

const repoURL = "https://github.com/mshddev/sonacli"

type browserOpener interface {
Open(ctx context.Context, url string) error
}

type systemBrowserOpener struct {
command string
}

var newStarOpener = func() (browserOpener, error) {
return newSystemBrowserOpener(runtime.GOOS)
}

func NewStarCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "star",
Short: "Star the sonacli GitHub repository",
Long: "Open the sonacli GitHub repository in your default browser so you can star it manually.",
Example: ` sonacli star`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
opener, err := newStarOpener()
if err != nil {
return err
}

if err := opener.Open(cmd.Context(), repoURL); err != nil {
return err
}

_, err = fmt.Fprintf(cmd.OutOrStdout(), "Opened %s in your browser.\n", repoURL)
return err
},
}

applyCommandTemplates(cmd)

return cmd
}

func newSystemBrowserOpener(goos string) (*systemBrowserOpener, error) {
switch goos {
case "darwin":
return &systemBrowserOpener{command: "open"}, nil
case "linux":
return &systemBrowserOpener{command: "xdg-open"}, nil
default:
return nil, fmt.Errorf("unsupported operating system: %s", goos)
}
}

func (o *systemBrowserOpener) Open(ctx context.Context, url string) error {
output, err := exec.CommandContext(ctx, o.command, url).CombinedOutput()
if err == nil {
return nil
}

message := strings.TrimSpace(string(output))
if message == "" {
return fmt.Errorf("open %s with %s: %w", url, o.command, err)
}

return fmt.Errorf("open %s with %s: %w: %s", url, o.command, err, message)
}
120 changes: 120 additions & 0 deletions internal/cli/star_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package cli

import (
"bytes"
"context"
"errors"
"testing"
)

func TestRunStarCommandOpensRepositoryURL(t *testing.T) {
originalFactory := newStarOpener
t.Cleanup(func() {
newStarOpener = originalFactory
})

opener := &stubBrowserOpener{}
newStarOpener = func() (browserOpener, error) {
return opener, nil
}

var stdout bytes.Buffer
var stderr bytes.Buffer

exitCode := Run([]string{"star"}, &stdout, &stderr)

if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}

if opener.url != repoURL {
t.Fatalf("opened URL = %q, want %q", opener.url, repoURL)
}

if got := stdout.String(); got != "Opened "+repoURL+" in your browser.\n" {
t.Fatalf("stdout = %q", got)
}

if stderr.Len() != 0 {
t.Fatalf("expected no stderr output, got %q", stderr.String())
}
}

func TestRunStarCommandShowsHelpWhenOpenFails(t *testing.T) {
originalFactory := newStarOpener
t.Cleanup(func() {
newStarOpener = originalFactory
})

newStarOpener = func() (browserOpener, error) {
return &stubBrowserOpener{err: errors.New("boom")}, nil
}

var stdout bytes.Buffer
var stderr bytes.Buffer

exitCode := Run([]string{"star"}, &stdout, &stderr)

if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}

if stdout.Len() != 0 {
t.Fatalf("expected no stdout output, got %q", stdout.String())
}

output := stderr.String()
if !bytes.Contains(stderr.Bytes(), []byte("Error: boom")) {
t.Fatalf("stderr = %q", output)
}

if !bytes.Contains(stderr.Bytes(), []byte("sonacli star")) {
t.Fatalf("stderr = %q", output)
}
}

func TestNewSystemBrowserOpenerSelectsCommandByOS(t *testing.T) {
t.Parallel()

testCases := []struct {
goos string
command string
}{
{goos: "darwin", command: "open"},
{goos: "linux", command: "xdg-open"},
}

for _, tc := range testCases {
t.Run(tc.goos, func(t *testing.T) {
t.Parallel()

opener, err := newSystemBrowserOpener(tc.goos)
if err != nil {
t.Fatalf("newSystemBrowserOpener(%q) returned error: %v", tc.goos, err)
}

if opener.command != tc.command {
t.Fatalf("command = %q, want %q", opener.command, tc.command)
}
})
}
}

func TestNewSystemBrowserOpenerRejectsUnsupportedOS(t *testing.T) {
t.Parallel()

opener, err := newSystemBrowserOpener("windows")
if err == nil {
t.Fatalf("expected error, got opener %+v", opener)
}
}

type stubBrowserOpener struct {
url string
err error
}

func (s *stubBrowserOpener) Open(_ context.Context, url string) error {
s.url = url
return s.err
}
54 changes: 54 additions & 0 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cli

import (
"context"
"fmt"

"github.com/mshddev/sonacli/internal/selfupdate"
"github.com/spf13/cobra"
)

type updateRunner interface {
Update(ctx context.Context, requestedVersion string) (selfupdate.Result, error)
}

var newUpdateRunner = func() (updateRunner, error) {
return selfupdate.NewFromEnvironment(Version)
}

func NewUpdateCmd() *cobra.Command {
var version string

cmd := &cobra.Command{
Use: "update",
Short: "Update sonacli to the latest version",
Long: "Download a sonacli release archive for the current operating system and CPU, verify checksums.txt, and replace the current executable in place. By default the latest GitHub release is installed.",
Example: ` sonacli update
sonacli update --version v0.1.0-rc.3`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
updater, err := newUpdateRunner()
if err != nil {
return err
}

result, err := updater.Update(cmd.Context(), version)
if err != nil {
return err
}

if !result.Updated {
_, err = fmt.Fprintf(cmd.OutOrStdout(), "sonacli %s is already installed.\nPath: %s\n", result.Version, result.ExecutablePath)
return err
}

_, err = fmt.Fprintf(cmd.OutOrStdout(), "Updated sonacli from %s to %s.\nPath: %s\n", result.PreviousVersion, result.Version, result.ExecutablePath)
return err
},
}

applyCommandTemplates(cmd)
cmd.Flags().StringVar(&version, "version", "", "Install a specific release tag instead of the latest release")

return cmd
}
Loading