diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..58dc6d2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Go mod download + run: go mod download + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -v ./... + + build-matrix: + name: Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build + env: + CGO_ENABLED: "0" + run: go build ./... + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.11.4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4b36ff5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + packages: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + # RELEASE_PAT is a classic PAT with `repo` scope, required for + # cross-repo pushes to captured-ventures/homebrew-rememberize and + # captured-ventures/scoop-rememberize. The default GITHUB_TOKEN + # cannot push to other repos. + GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..387fd64 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,30 @@ +version: "2" + +run: + timeout: 5m + tests: true + +linters: + default: none + enable: + - govet + - staticcheck + - ineffassign + - errcheck + settings: + errcheck: + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + - (io.Closer).Close + - (*os.File).Close + - (*text/tabwriter.Writer).Flush + +formatters: + enable: + - gofmt + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..0a85ebc --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,102 @@ +version: 2 + +project_name: rememberize-cli + +before: + hooks: + - go mod tidy + +builds: + - id: rememberize + binary: rememberize + main: ./cmd/rememberize + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X main.version={{.Version}} + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + +archives: + - id: rememberize + name_template: >- + rememberize_{{ .Version }}_{{ .Os }}_{{ .Arch }} + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - LICENSE + - README.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +snapshot: + version_template: "{{ incpatch .Version }}-snapshot-{{.ShortCommit}}" + +changelog: + use: github + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + - "Merge pull request" + - "Merge branch" + +release: + github: + owner: captured-ventures + name: rememberize-cli + draft: false + prerelease: auto + mode: replace + name_template: "v{{ .Version }}" + +brews: + - name: rememberize + repository: + owner: captured-ventures + name: homebrew-rememberize + branch: main + token: "{{ .Env.GITHUB_TOKEN }}" + directory: Formula + homepage: "https://rememberize.app" + description: "CLI client for rememberize — a portable, multi-directional memory system for AI." + license: "MIT" + commit_author: + name: rememberize-bot + email: bot@captured.ventures + install: | + bin.install "rememberize" + test: | + assert_match "rememberize", shell_output("#{bin}/rememberize --version") + +scoops: + - name: rememberize + repository: + owner: captured-ventures + name: scoop-rememberize + branch: main + token: "{{ .Env.GITHUB_TOKEN }}" + directory: bucket + homepage: "https://rememberize.app" + description: "CLI client for rememberize — a portable, multi-directional memory system for AI." + license: MIT + commit_author: + name: rememberize-bot + email: bot@captured.ventures diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8fdfcac --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +.PHONY: build test lint install-dev clean release-snapshot + +GIT_SHA := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +VERSION ?= dev-$(GIT_SHA) +BIN := bin/rememberize +INSTALL_DIR ?= $(HOME)/.local/bin + +build: + CGO_ENABLED=0 go build -o $(BIN) -ldflags "-s -w -X main.version=$(VERSION)" ./cmd/rememberize + +test: + go test -race ./... + +lint: + golangci-lint run + +install-dev: build + mkdir -p $(INSTALL_DIR) + cp $(BIN) $(INSTALL_DIR)/rememberize + @echo "Installed to $(INSTALL_DIR)/rememberize" + +clean: + rm -rf bin/ dist/ + +release-snapshot: + goreleaser release --snapshot --clean diff --git a/cmd/rememberize/main.go b/cmd/rememberize/main.go index 2fb7ac3..e1f12c7 100644 --- a/cmd/rememberize/main.go +++ b/cmd/rememberize/main.go @@ -644,14 +644,22 @@ func runImport(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("create form file: %w", err) } - part.Write(data) + if _, err := part.Write(data); err != nil { + return fmt.Errorf("write form part: %w", err) + } if importNamespace != "" { - writer.WriteField("namespace", importNamespace) + if err := writer.WriteField("namespace", importNamespace); err != nil { + return fmt.Errorf("write namespace field: %w", err) + } } if importFormat != "" { - writer.WriteField("format", importFormat) + if err := writer.WriteField("format", importFormat); err != nil { + return fmt.Errorf("write format field: %w", err) + } + } + if err := writer.Close(); err != nil { + return fmt.Errorf("close multipart writer: %w", err) } - writer.Close() req, err := client.newRequest("POST", "/api/import", body) if err != nil { @@ -741,7 +749,7 @@ func runExport(cmd *cobra.Command, args []string) error { } fmt.Printf("Exported to %s\n", exportOutput) } else { - os.Stdout.Write(data) + _, _ = os.Stdout.Write(data) } return nil } diff --git a/cmd/rememberize/pair_flow_test.go b/cmd/rememberize/pair_flow_test.go index 9e9703c..2914a2a 100644 --- a/cmd/rememberize/pair_flow_test.go +++ b/cmd/rememberize/pair_flow_test.go @@ -35,10 +35,10 @@ type recordedRequest struct { } type exchangeResponse struct { - APIKey string `json:"api_key"` - ServerURL string `json:"server_url"` - Connection exchangeRespConnection `json:"connection"` - Namespaces []exchangeRespNamespace `json:"namespaces"` + APIKey string `json:"api_key"` + ServerURL string `json:"server_url"` + Connection exchangeRespConnection `json:"connection"` + Namespaces []exchangeRespNamespace `json:"namespaces"` } type exchangeRespConnection struct { diff --git a/cmd/rememberize/pair_test.go b/cmd/rememberize/pair_test.go index c0f209b..9c22aa8 100644 --- a/cmd/rememberize/pair_test.go +++ b/cmd/rememberize/pair_test.go @@ -82,7 +82,9 @@ func TestWriteMCPConfig_MergesExistingFile(t *testing.T) { data, _ := os.ReadFile(path) var cfg map[string]any - json.Unmarshal(data, &cfg) + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshal written config: %v", err) + } servers := cfg["mcpServers"].(map[string]any) if _, ok := servers["other-server"]; !ok { diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..3bfe205 --- /dev/null +++ b/install.sh @@ -0,0 +1,187 @@ +#!/bin/sh +# rememberize CLI installer +# +# Usage: +# curl -sSL https://rememberize.app/install.sh | sh +# curl -sSL https://raw.githubusercontent.com/captured-ventures/rememberize-cli/main/install.sh | sh +# +# Environment variables: +# INSTALL_DIR Where to install the binary (default: $HOME/.local/bin) +# VERSION Specific version/tag to install (default: latest) +# REPO Override repo slug (default: captured-ventures/rememberize-cli) + +set -eu + +REPO="${REPO:-captured-ventures/rememberize-cli}" +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" +BIN_NAME="rememberize" + +log() { + printf '%s\n' "$*" >&2 +} + +die() { + log "error: $*" + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" +} + +need_cmd uname +need_cmd mkdir +need_cmd mv +need_cmd rm +need_cmd chmod + +# ----------------------------------------------------------------------------- +# Detect OS +# ----------------------------------------------------------------------------- +detect_os() { + os_raw=$(uname -s 2>/dev/null || echo unknown) + case "$os_raw" in + Darwin) echo "darwin" ;; + Linux) echo "linux" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) echo "windows" ;; + *) die "unsupported OS: $os_raw" ;; + esac +} + +# ----------------------------------------------------------------------------- +# Detect arch +# ----------------------------------------------------------------------------- +detect_arch() { + arch_raw=$(uname -m 2>/dev/null || echo unknown) + case "$arch_raw" in + x86_64|amd64) echo "amd64" ;; + arm64|aarch64) echo "arm64" ;; + *) die "unsupported architecture: $arch_raw" ;; + esac +} + +OS=$(detect_os) +ARCH=$(detect_arch) + +if [ "$OS" = "windows" ] && [ "$ARCH" = "arm64" ]; then + die "windows arm64 is not currently published" +fi + +# ----------------------------------------------------------------------------- +# Pick a downloader +# ----------------------------------------------------------------------------- +if command -v curl >/dev/null 2>&1; then + FETCH="curl -fsSL" + FETCH_OUT="curl -fsSL -o" +elif command -v wget >/dev/null 2>&1; then + FETCH="wget -qO-" + FETCH_OUT="wget -qO" +else + die "need curl or wget installed" +fi + +# ----------------------------------------------------------------------------- +# Resolve version +# ----------------------------------------------------------------------------- +TAG="${VERSION:-}" +if [ -z "$TAG" ]; then + log "resolving latest release from github.com/$REPO..." + RELEASES_URL="https://api.github.com/repos/$REPO/releases/latest" + # Extract "tag_name": "vX.Y.Z" without jq. + TAG=$($FETCH "$RELEASES_URL" 2>/dev/null \ + | grep '"tag_name":' \ + | head -n 1 \ + | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/') || TAG="" + if [ -z "$TAG" ]; then + die "could not determine latest release tag (no releases published yet?)" + fi +fi + +# Strip leading v for archive name +VERSION_NUM=$(printf '%s' "$TAG" | sed 's/^v//') + +# ----------------------------------------------------------------------------- +# Build download URL +# ----------------------------------------------------------------------------- +if [ "$OS" = "windows" ]; then + EXT="zip" +else + EXT="tar.gz" +fi + +ARCHIVE="rememberize_${VERSION_NUM}_${OS}_${ARCH}.${EXT}" +URL="https://github.com/${REPO}/releases/download/${TAG}/${ARCHIVE}" + +# ----------------------------------------------------------------------------- +# Download + extract +# ----------------------------------------------------------------------------- +TMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t rememberize) +trap 'rm -rf "$TMP_DIR"' EXIT INT TERM + +log "downloading $URL" +if ! $FETCH_OUT "$TMP_DIR/$ARCHIVE" "$URL"; then + die "download failed: $URL" +fi + +log "extracting $ARCHIVE" +case "$EXT" in + tar.gz) + need_cmd tar + tar -xzf "$TMP_DIR/$ARCHIVE" -C "$TMP_DIR" || die "extract failed" + ;; + zip) + if command -v unzip >/dev/null 2>&1; then + unzip -q "$TMP_DIR/$ARCHIVE" -d "$TMP_DIR" || die "extract failed" + else + die "unzip required to install on windows" + fi + ;; +esac + +# Locate binary in extracted tree +BIN_SRC="" +for candidate in \ + "$TMP_DIR/$BIN_NAME" \ + "$TMP_DIR/$BIN_NAME.exe" \ + "$TMP_DIR/rememberize_${VERSION_NUM}_${OS}_${ARCH}/$BIN_NAME" \ + "$TMP_DIR/rememberize_${VERSION_NUM}_${OS}_${ARCH}/$BIN_NAME.exe"; do + if [ -f "$candidate" ]; then + BIN_SRC="$candidate" + break + fi +done + +if [ -z "$BIN_SRC" ]; then + die "could not find $BIN_NAME binary in extracted archive" +fi + +# ----------------------------------------------------------------------------- +# Install +# ----------------------------------------------------------------------------- +mkdir -p "$INSTALL_DIR" || die "could not create $INSTALL_DIR" + +DEST="$INSTALL_DIR/$BIN_NAME" +if [ "$OS" = "windows" ]; then + DEST="$INSTALL_DIR/$BIN_NAME.exe" +fi + +mv "$BIN_SRC" "$DEST" || die "could not move binary to $DEST" +chmod +x "$DEST" 2>/dev/null || true + +# ----------------------------------------------------------------------------- +# Report +# ----------------------------------------------------------------------------- +cat >&2 < + +EOF diff --git a/internal/transfer/memory_md.go b/internal/transfer/memory_md.go index 6f7c484..69277fd 100644 --- a/internal/transfer/memory_md.go +++ b/internal/transfer/memory_md.go @@ -172,11 +172,11 @@ func (f *MemoryMDFormatter) Format(memories []Memory) ([]byte, error) { sb.WriteString("\n") } sb.WriteString("---\n") - sb.WriteString(fmt.Sprintf("name: %s\n", truncate(m.Content, 60))) + fmt.Fprintf(&sb, "name: %s\n", truncate(m.Content, 60)) if m.Metadata != nil { - sb.WriteString(fmt.Sprintf("description: %s\n", *m.Metadata)) + fmt.Fprintf(&sb, "description: %s\n", *m.Metadata) } - sb.WriteString(fmt.Sprintf("type: %s\n", m.Type)) + fmt.Fprintf(&sb, "type: %s\n", m.Type) sb.WriteString("---\n\n") sb.WriteString(m.Content) sb.WriteString("\n")