Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,6 @@ jobs:
# Upload latest version pointer
aws s3 cp latest-version s3://major-cli-releases/latest-version --acl public-read --content-type "text/plain"

# Upload install script
# Upload install scripts
aws s3 cp install.sh s3://major-cli-releases/install.sh --acl public-read --content-type "text/plain"
aws s3 cp install.ps1 s3://major-cli-releases/install.ps1 --acl public-read --content-type "text/plain"
5 changes: 4 additions & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ builds:
- -s -w
- -X github.com/major-technology/cli/cmd.Version={{.Version}}
- -X github.com/major-technology/cli/cmd.configFile=configs/prod.json
goos: [darwin, linux]
goos: [darwin, linux, windows]
goarch: [amd64, arm64]

archives:
- formats: [tar.gz] # <- v2 syntax
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
Expand Down
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: rerelease release dev-install dev-restore
.PHONY: rerelease release dev-install dev-restore dev-install-windows

# Build and install locally for development
dev-install:
Expand All @@ -17,6 +17,14 @@ dev-restore:
@cp ~/.major/bin/major.backup ~/.major/bin/major
@echo "Done!"

# Build and install locally for development on Windows (run from PowerShell)
dev-install-windows:
@echo "Building CLI with prod config..."
go build -ldflags "-X 'github.com/major-technology/cli/cmd.configFile=configs/prod.json'" -o major.exe .
@echo "Installing development build..."
@powershell -Command "New-Item -ItemType Directory -Path '$$env:USERPROFILE\.major\bin' -Force | Out-Null; Copy-Item major.exe '$$env:USERPROFILE\.major\bin\major.exe' -Force; Remove-Item major.exe"
@echo "Done! Run 'major --version' to verify."

rerelease:
@echo "Releasing version v$(VERSION)..."
git tag -d v$(VERSION)
Expand Down
7 changes: 7 additions & 0 deletions cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ Fish:

# To load completions for each session, execute once:
$ major completion fish > ~/.config/fish/completions/major.fish

PowerShell:

PS> major completion powershell | Out-String | Invoke-Expression

# To load completions for each session, add to your profile:
PS> major completion powershell >> $PROFILE
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Expand Down
131 changes: 122 additions & 9 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ func runInstall(cmd *cobra.Command) error {
return errors.WrapError("failed to get user home dir", err)
}

// Create completions directory
completionsDir := filepath.Join(home, ".major", "completions")
if err := os.MkdirAll(completionsDir, 0755); err != nil {
return errors.WrapError("failed to create completions directory", err)
}

cmd.Println(stepStyle.Render("+ Generating shell completions..."))

if runtime.GOOS == "windows" {
return installWindows(cmd, binDir, completionsDir, successStyle)
}

return installUnix(cmd, binDir, home, completionsDir, successStyle)
}

func installUnix(cmd *cobra.Command, binDir, home, completionsDir string, successStyle lipgloss.Style) error {
shell := os.Getenv("SHELL")
var configFile string
var shellType string
Expand All @@ -88,15 +104,6 @@ func runInstall(cmd *cobra.Command) error {
return nil
}

// Create completions directory
completionsDir := filepath.Join(home, ".major", "completions")
if err := os.MkdirAll(completionsDir, 0755); err != nil {
return errors.WrapError("failed to create completions directory", err)
}

// Generate completion script
cmd.Println(stepStyle.Render("▸ Generating shell completions..."))

var completionEntry string
switch shellType {
case "zsh":
Expand Down Expand Up @@ -180,3 +187,109 @@ source "%s"

return nil
}

func installWindows(cmd *cobra.Command, binDir, completionsDir string, successStyle lipgloss.Style) error {
// Generate PowerShell completion script
completionFile := filepath.Join(completionsDir, "major.ps1")
f, err := os.Create(completionFile)
if err != nil {
return errors.WrapError("failed to create powershell completion file", err)
}
defer f.Close()

if err := cmd.Root().GenPowerShellCompletionWithDesc(f); err != nil {
return errors.WrapError("failed to generate powershell completion", err)
}

// Add binDir to User PATH if not already present
if err := addToWindowsPath(binDir); err != nil {
cmd.Println(fmt.Sprintf("Could not update PATH automatically: %v", err))
cmd.Println(fmt.Sprintf("Please add %s to your PATH manually.", binDir))
}

// Set up PowerShell profile to source completion
if err := setupPowerShellProfile(cmd, completionFile, successStyle); err != nil {
// Non-fatal -- PATH is the important part
cmd.Println(fmt.Sprintf("Could not set up PowerShell completions: %v", err))
}

boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#00FF00")).
Padding(1, 2).
MarginTop(1)

msg := fmt.Sprintf("%s\n\nPlease restart your terminal to start using 'major'.",
successStyle.Render("Major CLI installed successfully!"))

cmd.Println(boxStyle.Render(msg))

return nil
}

func addToWindowsPath(binDir string) error {
// Use PowerShell's [Environment] API -- locale-independent and safe.
out, err := exec.Command("powershell", "-NoProfile", "-Command",
`[Environment]::GetEnvironmentVariable('Path','User')`).Output()
if err != nil {
// PowerShell not available; set PATH directly
return exec.Command("powershell", "-NoProfile", "-Command",
fmt.Sprintf(`[Environment]::SetEnvironmentVariable('Path','%s','User')`, binDir)).Run()
}

currentPath := strings.TrimSpace(string(out))

// Check if already in PATH (case-insensitive on Windows)
for _, p := range strings.Split(currentPath, ";") {
if strings.EqualFold(strings.TrimSpace(p), binDir) {
return nil // already there
}
}

newPath := currentPath + ";" + binDir

// Guard against PATH exceeding Windows limit
if len(newPath) > 32760 {
return fmt.Errorf("PATH would exceed Windows limit (%d chars)", len(newPath))
}

return exec.Command("powershell", "-NoProfile", "-Command",
fmt.Sprintf(`[Environment]::SetEnvironmentVariable('Path','%s','User')`, newPath)).Run()
}

func setupPowerShellProfile(cmd *cobra.Command, completionFile string, successStyle lipgloss.Style) error {
// Get PowerShell profile path
out, err := exec.Command("powershell", "-NoProfile", "-Command", "$PROFILE").Output()
if err != nil {
return fmt.Errorf("powershell not found")
}

profilePath := strings.TrimSpace(string(out))
if profilePath == "" {
return fmt.Errorf("empty profile path")
}

marker := "# Major CLI"
completionEntry := fmt.Sprintf("\n%s\n. '%s'\n", marker, completionFile)

// Check if already configured
content, err := os.ReadFile(profilePath)
if err == nil && strings.Contains(string(content), marker) {
cmd.Println(successStyle.Render("PowerShell completions already configured!"))
return nil
}

// Ensure profile directory exists
if err := os.MkdirAll(filepath.Dir(profilePath), 0755); err != nil {
return err
}

f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()

_, err = f.WriteString(completionEntry)
return err
}
30 changes: 30 additions & 0 deletions cmd/mcp/check_readonly.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package mcp
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"

mjrToken "github.com/major-technology/cli/clients/token"
Expand All @@ -23,6 +25,34 @@ var checkReadonlyCmd = &cobra.Command{
},
}

var checkReadonlyHookCmd = &cobra.Command{
Use: "check-readonly-hook",
Short: "Hook entry point: reads PreToolUse JSON from stdin",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
input, err := io.ReadAll(os.Stdin)
if err != nil || len(input) == 0 {
return nil
}

var payload struct {
ToolName string `json:"tool_name"`
}
if err := json.Unmarshal(input, &payload); err != nil || payload.ToolName == "" {
return nil
}

// Strip MCP server prefix
actualTool := payload.ToolName
const prefix = "mcp__plugin_major_major-resources__"
if strings.HasPrefix(actualTool, prefix) {
actualTool = actualTool[len(prefix):]
}

return runCheckReadonly(actualTool)
},
}

type toolMetadataItem struct {
Name string `json:"name"`
Description string `json:"description"`
Expand Down
28 changes: 28 additions & 0 deletions cmd/mcp/get_headers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package mcp

import (
"fmt"

mjrToken "github.com/major-technology/cli/clients/token"
"github.com/spf13/cobra"
)

var getHeadersCmd = &cobra.Command{
Use: "get-headers",
Short: "Output MCP authentication headers as JSON",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
token, err := mjrToken.GetToken()
if err != nil {
return err
}

orgID, _, err := mjrToken.GetDefaultOrg()
if err != nil {
return err
}

fmt.Fprintf(cmd.OutOrStdout(), `{"Authorization": "Bearer %s", "x-major-org-id": "%s"}`, token, orgID)
return nil
},
}
2 changes: 2 additions & 0 deletions cmd/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ var Cmd = &cobra.Command{

func init() {
Cmd.AddCommand(checkReadonlyCmd)
Cmd.AddCommand(checkReadonlyHookCmd)
Cmd.AddCommand(getHeadersCmd)
}
Loading