diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..90c271c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + go: ["1.25.x", "1.26.x"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go }} + cache: true + - name: Install golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: latest + args: --timeout=5m + - name: Test + run: make test + - name: Build + run: make build + - name: Cross build smoke + run: GOOS=linux GOARCH=arm64 go build ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b72ec02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/bin/ +/dist/ +*.test +coverage.out +.DS_Store +.env diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b6eafd9 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,19 @@ +version: "2" + +run: + timeout: 5m + +linters: + enable: + - errcheck + - gosec + - govet + - ineffassign + - revive + - staticcheck + - unused + +formatters: + enable: + - gofmt + - goimports diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dded421 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 onevcat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..53e30ba --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +BINARY := tang +VERSION ?= $(shell git describe --tags --always --dirty) +COMMIT ?= $(shell git rev-parse --short HEAD) + +.PHONY: build test lint install clean + +build: + go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/$(BINARY) ./cmd/tang + +test: + go test ./... + +lint: + golangci-lint run + +install: + go install -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" ./cmd/tang + +clean: + rm -rf bin diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a1594a --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# tang + +`tang` is a work-in-progress command-line client for Tangled. + +The project is not yet usable for day-to-day workflows. The initial milestones build toward auth, repository, issue, pull request, SSH key, status, config, browse, and completion commands. + +## Development + +```sh +make test +make build +./bin/tang --help +``` diff --git a/ai-docs/2026-05-02-INIT_PLAN.md b/ai-docs/2026-05-02-INIT_PLAN.md index db2dde4..d728d06 100644 --- a/ai-docs/2026-05-02-INIT_PLAN.md +++ b/ai-docs/2026-05-02-INIT_PLAN.md @@ -343,9 +343,10 @@ url = "https://tangled.org" 4. `cmd/tang/main.go`:cobra root + `version` + `--help` 5. `internal/output/output.go`:定义 `Renderer` interface,先实现一个最简 stdout printer(带 `--json` 占位) 6. Makefile 目标:`build` / `test` / `lint` / `install` -7. `.github/workflows/ci.yml`:跑 `go test ./...` + `golangci-lint run` + `go build ./...` +7. 基本测试:跑 `go test ./...` + `golangci-lint run` + `go build ./...` 8. `.golangci.yml`:开默认 + `gofmt`, `goimports`, `revive`, `errcheck`, `staticcheck` 9. `README.md`:占位("WIP, not yet usable") +10. 为项目添加合适的 .gitignore **验收**: diff --git a/ai-docs/2026-05-02-PHASE_0_skeleton.md b/ai-docs/2026-05-02-PHASE_0_skeleton.md new file mode 100644 index 0000000..a2293de --- /dev/null +++ b/ai-docs/2026-05-02-PHASE_0_skeleton.md @@ -0,0 +1,37 @@ +# Phase 0 — Skeleton and Infrastructure + +- **Date**: 2026-05-02 +- **Status**: In Progress + +## Goal + +Create a buildable, testable Go CLI skeleton for `tang`: module setup, Cobra root command, version command, basic output renderer, Makefile, lint config, CI, README, license, and ignore rules. + +## Constraints + +- Module path starts as `tangled.org/onev.cat/tang`. +- Pin `tangled.org/core` to `37303f21368b8e8567e3b96205a72481190ab232`. +- Keep implementation minimal and reviewable; no feature behavior beyond skeleton. +- Avoid hardcoded PDS defaults. + +## Plan + +1. Initialize `go.mod` and fetch the pinned Tangled core dependency. +2. Add the planned directory structure and package placeholders. +3. Implement `cmd/tang/main.go`, `internal/cli/root.go`, `completion`, and `version`. +4. Add the initial output renderer and tests for JSON field filtering. +5. Add Makefile, lint config, CI workflow, README, LICENSE, and `.gitignore`. +6. Run automated validation: `go test ./...`, `make build`, `./bin/tang version`, `./bin/tang --help`, lint when available, and cross-build smoke. + +## Validation Log + +- Completed: `go get tangled.org/core@37303f21368b8e8567e3b96205a72481190ab232` succeeded through the module version `v1.13.0-alpha.0.20260502074102-37303f21368b`. +- Completed: `make test` passed for all packages. +- Completed: `make build && ./bin/tang version` passed and printed the injected version/commit. +- Completed: `./bin/tang --help` lists `version` and `completion`. +- Completed: `GOOS=linux GOARCH=amd64 go build ./cmd/tang` passed. +- Completed: installed `golangci-lint` v2 locally and `make lint` passed with 0 issues. + +## Completion + +Phase 0 is complete. No human-only validation remains for this phase. diff --git a/cmd/tang/main.go b/cmd/tang/main.go new file mode 100644 index 0000000..938bbeb --- /dev/null +++ b/cmd/tang/main.go @@ -0,0 +1,23 @@ +// Command tang is the Tangled command-line client. +package main + +import ( + "os" + + "tangled.org/onev.cat/tang/internal/cli" +) + +var ( + version = "dev" + commit = "none" +) + +func main() { + root := cli.NewRootCommand(cli.BuildInfo{ + Version: version, + Commit: commit, + }) + if err := root.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2b82739 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module tangled.org/onev.cat/tang + +go 1.25.0 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/charmbracelet/lipgloss v1.1.0 + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 + github.com/olekukonko/tablewriter v1.1.4 + github.com/pelletier/go-toml/v2 v2.3.1 + github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/testify v1.11.1 + github.com/zalando/go-keyring v0.2.8 + tangled.org/core v1.13.0-alpha.0.20260502074102-37303f21368b +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fad4b4a --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +tangled.org/core v1.13.0-alpha.0.20260502074102-37303f21368b/go.mod h1:abXVlFoPAeM9pZier/WF1Cnn1ZoO9YE5h59WLEUy+Hk= diff --git a/internal/atproto/doc.go b/internal/atproto/doc.go new file mode 100644 index 0000000..47fd798 --- /dev/null +++ b/internal/atproto/doc.go @@ -0,0 +1,2 @@ +// Package atproto resolves AT Protocol identities and PDS endpoints. +package atproto diff --git a/internal/auth/doc.go b/internal/auth/doc.go new file mode 100644 index 0000000..3bd6946 --- /dev/null +++ b/internal/auth/doc.go @@ -0,0 +1,2 @@ +// Package auth manages Tangled sessions and credential storage. +package auth diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..83c6d73 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,83 @@ +// Package cli builds the tang command tree. +package cli + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" +) + +// BuildInfo contains values injected at build time. +type BuildInfo struct { + Version string + Commit string +} + +// RootOptions contains global options shared by subcommands. +type RootOptions struct { + JSONFields string + Repo string + PDS string +} + +// NewRootCommand creates the Cobra command tree. +func NewRootCommand(build BuildInfo) *cobra.Command { + opts := &RootOptions{} + cmd := &cobra.Command{ + Use: "tang", + Short: "A command-line client for Tangled", + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.PersistentFlags().StringVar(&opts.JSONFields, "json", "", "Output JSON, optionally filtered by comma-separated fields") + cmd.PersistentFlags().StringVarP(&opts.Repo, "repo", "R", "", "Select another repository using [HOST/]OWNER/REPO") + cmd.PersistentFlags().StringVar(&opts.PDS, "pds", "", "Override PDS URL for auth and testing") + + cmd.AddCommand(newVersionCommand(build)) + cmd.AddCommand(newCompletionCommand(cmd)) + return cmd +} + +func newVersionCommand(build BuildInfo) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version information", + RunE: func(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "tang version %s (%s)\n", build.Version, build.Commit) + return err + }, + } +} + +func newCompletionCommand(root *cobra.Command) *cobra.Command { + cmd := &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + switch args[0] { + case "bash": + return root.GenBashCompletion(out) + case "zsh": + return root.GenZshCompletion(out) + case "fish": + return root.GenFishCompletion(out, true) + case "powershell": + return root.GenPowerShellCompletion(out) + default: + return fmt.Errorf("unsupported shell %q", args[0]) + } + }, + } + return cmd +} + +func executeForTest(cmd *cobra.Command, out io.Writer, args ...string) error { + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetArgs(args) + return cmd.Execute() +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..01e5ab5 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,33 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestVersionCommand(t *testing.T) { + cmd := NewRootCommand(BuildInfo{Version: "v0.1.0", Commit: "abc123"}) + var out bytes.Buffer + if err := executeForTest(cmd, &out, "version"); err != nil { + t.Fatalf("version command failed: %v", err) + } + got := out.String() + if !strings.Contains(got, "v0.1.0") || !strings.Contains(got, "abc123") { + t.Fatalf("version output = %q", got) + } +} + +func TestHelpIncludesCoreCommands(t *testing.T) { + cmd := NewRootCommand(BuildInfo{Version: "dev", Commit: "none"}) + var out bytes.Buffer + if err := executeForTest(cmd, &out, "--help"); err != nil { + t.Fatalf("help command failed: %v", err) + } + got := out.String() + for _, want := range []string{"version", "completion"} { + if !strings.Contains(got, want) { + t.Fatalf("help output missing %q:\n%s", want, got) + } + } +} diff --git a/internal/config/doc.go b/internal/config/doc.go new file mode 100644 index 0000000..2d7bd75 --- /dev/null +++ b/internal/config/doc.go @@ -0,0 +1,2 @@ +// Package config reads and writes tang configuration. +package config diff --git a/internal/constellation/doc.go b/internal/constellation/doc.go new file mode 100644 index 0000000..93369c2 --- /dev/null +++ b/internal/constellation/doc.go @@ -0,0 +1,2 @@ +// Package constellation queries backlink indexes. +package constellation diff --git a/internal/git/doc.go b/internal/git/doc.go new file mode 100644 index 0000000..2016474 --- /dev/null +++ b/internal/git/doc.go @@ -0,0 +1,2 @@ +// Package git wraps repository inspection and git commands. +package git diff --git a/internal/output/json.go b/internal/output/json.go new file mode 100644 index 0000000..ad8d8d3 --- /dev/null +++ b/internal/output/json.go @@ -0,0 +1,55 @@ +package output + +import ( + "encoding/json" + "fmt" +) + +// FilterFields keeps only the selected top-level fields on an object or object list. +func FilterFields(value any, fields []string) (any, error) { + if len(fields) == 0 { + return value, nil + } + + raw, err := json.Marshal(value) + if err != nil { + return nil, err + } + + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, err + } + + allowed := make(map[string]struct{}, len(fields)) + for _, field := range fields { + allowed[field] = struct{}{} + } + + switch v := decoded.(type) { + case map[string]any: + return filterMap(v, allowed), nil + case []any: + out := make([]any, len(v)) + for i, item := range v { + m, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("field filtering requires object values") + } + out[i] = filterMap(m, allowed) + } + return out, nil + default: + return nil, fmt.Errorf("field filtering requires object values") + } +} + +func filterMap(input map[string]any, allowed map[string]struct{}) map[string]any { + out := make(map[string]any, len(allowed)) + for key, value := range input { + if _, ok := allowed[key]; ok { + out[key] = value + } + } + return out +} diff --git a/internal/output/json_test.go b/internal/output/json_test.go new file mode 100644 index 0000000..a2953b3 --- /dev/null +++ b/internal/output/json_test.go @@ -0,0 +1,39 @@ +package output + +import ( + "encoding/json" + "testing" +) + +func TestFilterFieldsObject(t *testing.T) { + input := map[string]any{ + "number": 1, + "title": "Fix parser", + "state": "open", + } + got, err := FilterFields(input, []string{"title", "number"}) + if err != nil { + t.Fatalf("FilterFields returned error: %v", err) + } + + raw, _ := json.Marshal(got) + if string(raw) != `{"number":1,"title":"Fix parser"}` { + t.Fatalf("filtered JSON = %s", raw) + } +} + +func TestFilterFieldsList(t *testing.T) { + input := []map[string]any{ + {"number": 1, "title": "One", "state": "open"}, + {"number": 2, "title": "Two", "state": "closed"}, + } + got, err := FilterFields(input, []string{"title"}) + if err != nil { + t.Fatalf("FilterFields returned error: %v", err) + } + + raw, _ := json.Marshal(got) + if string(raw) != `[{"title":"One"},{"title":"Two"}]` { + t.Fatalf("filtered JSON = %s", raw) + } +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..698393d --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,69 @@ +// Package output renders command results. +package output + +import ( + "encoding/json" + "fmt" + "io" + "strings" +) + +// Mode describes the output format selected for a command. +type Mode string + +const ( + // ModeText renders human-readable output. + ModeText Mode = "text" + // ModeJSON renders JSON output. + ModeJSON Mode = "json" +) + +// Renderer writes command results to an output stream. +type Renderer interface { + Render(w io.Writer, value any) error +} + +// JSONRenderer renders values as JSON with optional top-level field filtering. +type JSONRenderer struct { + Fields []string +} + +// NewJSONRenderer creates a JSON renderer from a comma-separated field list. +func NewJSONRenderer(fields string) JSONRenderer { + return JSONRenderer{Fields: parseFields(fields)} +} + +// Render writes filtered JSON. +func (r JSONRenderer) Render(w io.Writer, value any) error { + filtered, err := FilterFields(value, r.Fields) + if err != nil { + return err + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(filtered) +} + +// TextRenderer renders values with fmt.Fprintln. +type TextRenderer struct{} + +// Render writes a human-readable value. +func (TextRenderer) Render(w io.Writer, value any) error { + _, err := fmt.Fprintln(w, value) + return err +} + +func parseFields(fields string) []string { + if strings.TrimSpace(fields) == "" { + return nil + } + parts := strings.Split(fields, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + field := strings.TrimSpace(part) + if field != "" { + out = append(out, field) + } + } + return out +} diff --git a/internal/output/style.go b/internal/output/style.go new file mode 100644 index 0000000..e9d55ef --- /dev/null +++ b/internal/output/style.go @@ -0,0 +1,3 @@ +package output + +// TTY styling is introduced with human-readable command output. diff --git a/internal/output/table.go b/internal/output/table.go new file mode 100644 index 0000000..76e3151 --- /dev/null +++ b/internal/output/table.go @@ -0,0 +1,3 @@ +package output + +// Table rendering is introduced with the first list commands. diff --git a/internal/repo/doc.go b/internal/repo/doc.go new file mode 100644 index 0000000..b905ded --- /dev/null +++ b/internal/repo/doc.go @@ -0,0 +1,2 @@ +// Package repo resolves Tangled repository context. +package repo diff --git a/internal/tangled/doc.go b/internal/tangled/doc.go new file mode 100644 index 0000000..6075c40 --- /dev/null +++ b/internal/tangled/doc.go @@ -0,0 +1,2 @@ +// Package tangled wraps Tangled PDS and Knot APIs. +package tangled diff --git a/tests/integration/doc.go b/tests/integration/doc.go new file mode 100644 index 0000000..682e773 --- /dev/null +++ b/tests/integration/doc.go @@ -0,0 +1,2 @@ +// Package integration contains opt-in end-to-end tests. +package integration