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 .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ updates:
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
68 changes: 68 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: jdx/mise-action@v4
with:
cache: true

- name: Verify formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "::error::The following files are not gofmt'd:"
echo "$unformatted"
exit 1
fi

- name: Run linter
run: mise run lint

test:
name: Test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v6

- uses: jdx/mise-action@v4
with:
cache: true

- name: Run tests
run: mise run test

build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- uses: jdx/mise-action@v4
with:
cache: true

- name: Build binary
run: mise run build
4 changes: 2 additions & 2 deletions cli/code.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ func looksLikeQRPayload(s string) bool {

func newCodeGenerateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "generate",
Short: "Generate a QR code and manual pairing code from parameters",
Use: "generate",
Short: "Generate a QR code and manual pairing code from parameters",
Example: ` matter code generate --vid 0xFFF1 --pid 0x8000 --passcode 12345678 --discriminator 3840`,
RunE: func(cmd *cobra.Command, args []string) error {
vid, _ := cmd.Flags().GetUint16("vid")
Expand Down
8 changes: 4 additions & 4 deletions cli/commission_window.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import (
"github.com/p0fi/matter-cli/internal/daemon"
"github.com/p0fi/matter-cli/internal/interaction"
"github.com/p0fi/matter-cli/internal/protocol"
"github.com/p0fi/matter-cli/internal/tlv"
"github.com/p0fi/matter-cli/internal/store"
"github.com/p0fi/matter-cli/internal/tlv"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
Expand All @@ -32,9 +32,9 @@ const timedInteractionTimeoutMs uint16 = 5_000

// Administrator Commissioning cluster-specific status codes (Matter spec §11.19.6).
const (
adminBusy uint8 = 0x02
adminPAKEParamError uint8 = 0x03
adminWindowNotOpen uint8 = 0x04
adminBusy uint8 = 0x02
adminPAKEParamError uint8 = 0x03
adminWindowNotOpen uint8 = 0x04
)

// newCommissionOpenWindowCmd creates the `commission open-window` subcommand.
Expand Down
56 changes: 47 additions & 9 deletions cli/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import (
"github.com/spf13/cobra"
)

// supportedShells is the single source of truth for shells that matter completion supports.
// Used for both ValidArgs and error messages in detectShell.
var supportedShells = []string{"bash", "zsh", "fish", "powershell"}

// newCompletionCmd creates the `matter completion` subcommand that generates
// and optionally installs shell completion scripts for bash, zsh, fish, and
// powershell.
Expand All @@ -26,12 +30,19 @@ func newCompletionCmd() *cobra.Command {
Short: "Generate or install shell completion scripts",
Long: `Generate shell completion scripts for matter.

By default, the completion script is printed to stdout so you can redirect it
to a file or pipe it to your shell.
With no arguments, matter completion auto-detects your shell from $SHELL and
installs the completion script automatically. On Windows, no-argument
invocation always installs PowerShell completions regardless of $SHELL.

Specify a shell explicitly to print its completion script to stdout. Add
--install to install explicitly for a given shell.`,
Comment thread
p0fi marked this conversation as resolved.
Example: ` # Auto-detect shell and install completions (recommended)
matter completion

Use --install to automatically write the script to the correct location for
your shell and configure it for use.`,
Example: ` # Print zsh completions to stdout
# On Windows: always installs PowerShell completions
matter completion

# Print zsh completions to stdout
matter completion zsh

# Install zsh completions (writes file + configures shell)
Expand All @@ -42,8 +53,8 @@ your shell and configure it for use.`,

# Install fish completions
matter completion fish --install`,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
ValidArgs: supportedShells,
Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs),
RunE: runCompletion,
}

Expand All @@ -53,16 +64,43 @@ your shell and configure it for use.`,
}

func runCompletion(cmd *cobra.Command, args []string) error {
shell := args[0]
install, _ := cmd.Flags().GetBool("install")

if len(args) == 0 {
shell, err := detectShell()
if err != nil {
return err
}
return installCompletion(cmd, shell)
}

shell := args[0]
if !install {
return generateCompletion(cmd, shell)
}

return installCompletion(cmd, shell)
}

// detectShell returns the name of the current user's shell by inspecting $SHELL.
// On Windows it defaults to "powershell". Returns an error when the shell
// cannot be detected or is not among the supported set.
func detectShell() (string, error) {
if runtime.GOOS == "windows" {
return "powershell", nil
}
shellEnv := os.Getenv("SHELL")
if shellEnv == "" {
return "", fmt.Errorf("could not detect your shell — please specify one: %s", strings.Join(supportedShells, ", "))
}
name := filepath.Base(shellEnv)
switch name {
case "bash", "zsh", "fish":
return name, nil
default:
return "", fmt.Errorf("unsupported shell %q — please specify one: %s", name, strings.Join(supportedShells, ", "))
}
Comment thread
p0fi marked this conversation as resolved.
}

// generateCompletion prints the completion script to stdout.
func generateCompletion(cmd *cobra.Command, shell string) error {
root := cmd.Root()
Expand Down
84 changes: 82 additions & 2 deletions cli/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,95 @@ func TestCompletionInvalidShell(t *testing.T) {
assert.Error(t, err)
}

func TestCompletionNoArgs(t *testing.T) {
func TestCompletionNoArgs_UnsetShell(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("windows always falls back to powershell")
}
t.Setenv("SHELL", "")

root, _ := newTestRootWithCompletion()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"completion"})

err := root.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "could not detect")
}

func TestCompletionNoArgs_AutoDetect(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("windows always falls back to powershell")
}
shells := []string{"bash", "zsh", "fish"}
for _, shell := range shells {
t.Run(shell, func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("SHELL", "/bin/"+shell)

root, _ := newTestRootWithCompletion()
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetArgs([]string{"completion"})

err := root.Execute()
require.NoError(t, err)
assert.Contains(t, stderr.String(), "✓")
})
}
}

func TestCompletionNoArgs_UnsupportedShell(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("windows always falls back to powershell")
}
t.Setenv("SHELL", "/bin/nushell")

root, _ := newTestRootWithCompletion()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"completion"})

err := root.Execute()
assert.Error(t, err)
require.Error(t, err)
assert.Contains(t, err.Error(), "nushell")
}

func TestDetectShell(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("windows detection is separate")
}
tests := []struct {
shellEnv string
wantShell string
wantErr bool
errContains string
}{
{"/bin/bash", "bash", false, ""},
{"/usr/local/bin/zsh", "zsh", false, ""},
{"/usr/bin/fish", "fish", false, ""},
{"", "", true, "could not detect"},
{"/bin/nushell", "", true, "nushell"},
{"/bin/dash", "", true, "dash"},
}
for _, tt := range tests {
t.Run(tt.shellEnv, func(t *testing.T) {
t.Setenv("SHELL", tt.shellEnv)
got, err := detectShell()
if tt.wantErr {
require.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
} else {
require.NoError(t, err)
assert.Equal(t, tt.wantShell, got)
}
})
}
}

func TestCompletionInstallPath_Zsh(t *testing.T) {
Expand Down
38 changes: 20 additions & 18 deletions cli/decommission.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,17 +177,18 @@ func readCurrentFabricIndex(ctx context.Context, nodeID uint64) (uint8, error) {
if err != nil {
return 0, err
}
for _, r := range resp.Reports {
if r.StatusCode != 0 {
return 0, fmt.Errorf("status 0x%02X", r.StatusCode)
}
data, derr := daemon.DecodeFields(r.Data)
if derr != nil {
return 0, fmt.Errorf("decoding fields: %w", derr)
}
return decodeFabricIndex(data)
if len(resp.Reports) == 0 {
return 0, fmt.Errorf("no report data")
}
return 0, fmt.Errorf("no report data")
r := resp.Reports[0]
if r.StatusCode != 0 {
return 0, fmt.Errorf("status 0x%02X", r.StatusCode)
}
data, derr := daemon.DecodeFields(r.Data)
if derr != nil {
return 0, fmt.Errorf("decoding fields: %w", derr)
}
return decodeFabricIndex(data)
}

client, session, cleanup, err := connectToNode(ctx, nodeID)
Expand All @@ -201,13 +202,15 @@ func readCurrentFabricIndex(ctx context.Context, nodeID uint64) (uint8, error) {
if err != nil {
return 0, err
}
for _, r := range reports {
if r.Status != nil {
return 0, fmt.Errorf("status 0x%02X", r.Status.Status.Status)
}
if r.Data != nil {
return decodeFabricIndex(r.Data.Data)
}
if len(reports) == 0 {
return 0, fmt.Errorf("no report data")
}
r := reports[0]
if r.Status != nil {
return 0, fmt.Errorf("status 0x%02X", r.Status.Status.Status)
}
if r.Data != nil {
return decodeFabricIndex(r.Data.Data)
}
return 0, fmt.Errorf("no report data")
}
Expand Down Expand Up @@ -385,4 +388,3 @@ func confirmForceDelete(cmd *cobra.Command, stepper *output.Stepper, force bool,
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
return answer == "y" || answer == "yes"
}

14 changes: 7 additions & 7 deletions cli/output/color.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ import (

var (
// Core colors — ANSI numbers, resolved by the terminal's own palette.
colorGreen = lipgloss.ANSIColor(10) // Bright Green → success
colorRed = lipgloss.ANSIColor(9) // Bright Red → errors
colorYellow = lipgloss.ANSIColor(3) // Yellow → warnings
colorBlue = lipgloss.ANSIColor(12) // Bright Blue → info / headers
colorCyan = lipgloss.ANSIColor(14) // Bright Cyan → labels / commands
colorMagenta = lipgloss.ANSIColor(13) // Bright Magenta→ accents / IDs
colorGreen = lipgloss.ANSIColor(10) // Bright Green → success
colorRed = lipgloss.ANSIColor(9) // Bright Red → errors
colorYellow = lipgloss.ANSIColor(3) // Yellow → warnings
colorBlue = lipgloss.ANSIColor(12) // Bright Blue → info / headers
colorCyan = lipgloss.ANSIColor(14) // Bright Cyan → labels / commands
colorMagenta = lipgloss.ANSIColor(13) // Bright Magenta→ accents / IDs
colorLightGray = lipgloss.ANSIColor(7) // Light Gray → values
colorGray = lipgloss.ANSIColor(8) // Bright Black (Dark Gray) → muted/secondary
colorGray = lipgloss.ANSIColor(8) // Bright Black (Dark Gray) → muted/secondary

// StyleSuccess renders text in green.
StyleSuccess = lipgloss.NewStyle().Foreground(colorGreen)
Expand Down
4 changes: 2 additions & 2 deletions cli/output/tree_d2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,8 @@ func TestBuildD2Script_UtilityClusterOpacity(t *testing.T) {
{
ID: 0,
Clusters: []TreeCluster{
{ID: 0x001D, Name: "Descriptor"}, // utility
{ID: 0x0006, Name: "OnOff"}, // application
{ID: 0x001D, Name: "Descriptor"}, // utility
{ID: 0x0006, Name: "OnOff"}, // application
},
},
},
Expand Down
Loading