From fbd1947ae2d4f1bdb82372f1fb60ba9fe5e51f1b Mon Sep 17 00:00:00 2001 From: Yves Junqueira Date: Wed, 25 Mar 2026 20:02:58 -0300 Subject: [PATCH] Add pipeboard CLI with MCP client, config, and CI Implements the initial pipeboard CLI: a thin Go client that speaks JSON-RPC 2.0 to Pipeboard MCP servers for managing Meta, Google, and TikTok ads. Includes config management, auth helpers, debug commands, CI workflow, goreleaser config, and Apache 2.0 license. --- .github/workflows/ci.yml | 48 ++++++++++ .gitignore | 6 ++ .goreleaser.yml | 41 ++++++++ CLAUDE.md | 50 ++++++++++ LICENSE | 190 ++++++++++++++++++++++++++++++++++++ Makefile | 20 ++++ README.md | 77 +++++++++++++++ cmd/config.go | 145 ++++++++++++++++++++++++++++ cmd/debug.go | 157 ++++++++++++++++++++++++++++++ cmd/login.go | 31 ++++++ cmd/root.go | 64 +++++++++++++ cmd/version.go | 15 +++ go.mod | 10 ++ go.sum | 10 ++ internal/client/mcp.go | 202 +++++++++++++++++++++++++++++++++++++++ main.go | 13 +++ 16 files changed, 1079 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .goreleaser.yml create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/config.go create mode 100644 cmd/debug.go create mode 100644 cmd/login.go create mode 100644 cmd/root.go create mode 100644 cmd/version.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/client/mcp.go create mode 100644 main.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2bd958b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + + - name: Build + run: make build + + - name: Test + run: make test + + - name: Lint + run: make lint + + release: + needs: test + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + + - uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index aaadf73..f71a4ea 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,12 @@ go.work.sum # env file .env +# Build output +bin/ + +# Conductor workspace +.context/ + # Editor/IDE # .idea/ # .vscode/ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..5122b6f --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,41 @@ +version: 2 + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X github.com/pipeboard-co/pipeboard-cli/cmd.Version={{.Version}} + - -X github.com/pipeboard-co/pipeboard-cli/cmd.Commit={{.ShortCommit}} + +archives: + - format: tar.gz + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + format: zip + +brews: + - repository: + owner: pipeboard-co + name: homebrew-tap + homepage: "https://github.com/pipeboard-co/pipeboard-cli" + description: "CLI for managing Meta, Google, and TikTok Ads via Pipeboard" + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c0e0c80 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md + +## Project Overview + +pipeboard-cli is a Go CLI for managing ads across Meta, Google, and TikTok via Pipeboard's MCP servers. All operations go through Pipeboard servers — the CLI is a thin client. + +**Stack:** Go 1.23 + Cobra CLI framework + goreleaser + +## Common Commands + +```bash +make build # Build binary to bin/pipeboard +make install # Install to $GOPATH/bin +make test # Run tests +make lint # Run go vet +``` + +## Architecture + +``` +main.go # Entry point +cmd/ # Cobra commands + root.go # Root command, auth helper, global flags + version.go # Version command + login.go # Browser OAuth flow (TODO) + config.go # Config file management (~/.pipeboard/config.json) + debug.go # Hidden debug command for raw API queries +internal/ + client/mcp.go # MCP JSON-RPC client over HTTP +``` + +### MCP Protocol + +The CLI speaks JSON-RPC 2.0 over HTTP POST to `https://mcp.pipeboard.co/`: +- `initialize` — handshake +- `tools/list` — discover available tools +- `tools/call` — invoke a tool +- Auth: `Authorization: Bearer ` header + +### Auth + +Token resolution order: +1. `PIPEBOARD_API_TOKEN` env var +2. `~/.pipeboard/config.json` token field + +### Build-time variables + +Version and commit are injected via ldflags (see Makefile and .goreleaser.yml): +- `cmd.Version` +- `cmd.Commit` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1158bf9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Pipeboard + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..701be9c --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "none") +LDFLAGS := -ldflags "-X github.com/pipeboard-co/pipeboard-cli/cmd.Version=$(VERSION) -X github.com/pipeboard-co/pipeboard-cli/cmd.Commit=$(COMMIT)" + +.PHONY: build install test lint clean + +build: + go build $(LDFLAGS) -o bin/pipeboard . + +install: + go install $(LDFLAGS) . + +test: + go test ./... + +lint: + go vet ./... + +clean: + rm -rf bin/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4be27f4 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# pipeboard-cli + +Command-line tool for managing Meta Ads, Google Ads, and TikTok Ads from your terminal. Create campaigns, pull reports, and manage ads across platforms with a single CLI. + +All operations go through [Pipeboard](https://pipeboard.co) servers — no direct API calls to ad platforms, no credentials stored locally. + +## Installation + +### Homebrew (macOS/Linux) + +```bash +brew install pipeboard-co/tap/pipeboard +``` + +### Go install + +```bash +go install github.com/pipeboard-co/pipeboard-cli@latest +``` + +### Binary download + +Download the latest release from [GitHub Releases](https://github.com/pipeboard-co/pipeboard-cli/releases). + +## Authentication + +### API Token (recommended for automation) + +```bash +export PIPEBOARD_API_TOKEN= +``` + +Or store it in the config file: + +```bash +pipeboard config set token +``` + +Get your API token at [pipeboard.co/settings](https://pipeboard.co/settings). + +### Browser OAuth (interactive) + +```bash +pipeboard login +``` + +## Usage + +Commands are auto-generated from Pipeboard's MCP tool definitions. Run `pipeboard --help` to see available commands. + +```bash +# List Google Ads campaigns +pipeboard google-ads list-campaigns --customer-id 1234567890 + +# Get Meta Ads insights +pipeboard meta-ads get-insights --account-id act_123 --date-range last_30d + +# Check version +pipeboard version +``` + +## Development + +```bash +# Build +make build + +# Run tests +make test + +# Install locally +make install +``` + +## License + +Apache License 2.0 — see [LICENSE](LICENSE). diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..02897cb --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +// Config stores CLI configuration. +type Config struct { + Token string `json:"token,omitempty"` + APIURL string `json:"api_url,omitempty"` +} + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Manage CLI configuration", +} + +var configSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set a configuration value", + Long: "Set a configuration value. Supported keys: token, api-url", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + key, value := args[0], args[1] + + cfg, err := loadConfig() + if err != nil { + cfg = &Config{} + } + + switch key { + case "token": + cfg.Token = value + case "api-url": + cfg.APIURL = value + default: + return fmt.Errorf("unknown config key: %s (supported: token, api-url)", key) + } + + if err := saveConfig(cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + fmt.Printf("Set %s\n", key) + return nil + }, +} + +var configGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a configuration value", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + switch args[0] { + case "token": + if cfg.Token == "" { + fmt.Println("(not set)") + } else { + // Show only prefix for security + if len(cfg.Token) > 8 { + fmt.Printf("%s...%s\n", cfg.Token[:4], cfg.Token[len(cfg.Token)-4:]) + } else { + fmt.Println("(set)") + } + } + case "api-url": + if cfg.APIURL == "" { + fmt.Println(getDefaultAPIURL()) + } else { + fmt.Println(cfg.APIURL) + } + default: + return fmt.Errorf("unknown config key: %s", args[0]) + } + return nil + }, +} + +func init() { + configCmd.AddCommand(configSetCmd) + configCmd.AddCommand(configGetCmd) +} + +func configDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".pipeboard"), nil +} + +func configPath() (string, error) { + dir, err := configDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.json"), nil +} + +func loadConfig() (*Config, error) { + path, err := configPath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func saveConfig(cfg *Config) error { + dir, err := configDir() + if err != nil { + return err + } + + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + path := filepath.Join(dir, "config.json") + return os.WriteFile(path, data, 0600) +} diff --git a/cmd/debug.go b/cmd/debug.go new file mode 100644 index 0000000..50d7d6b --- /dev/null +++ b/cmd/debug.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/pipeboard-co/pipeboard-cli/internal/client" + "github.com/spf13/cobra" +) + +var debugCmd = &cobra.Command{ + Use: "debug", + Short: "Send raw API queries through Pipeboard (admin only)", + Hidden: true, +} + +// --- Google Ads debug --- + +var debugGoogleAdsCmd = &cobra.Command{ + Use: "google-ads", + Short: "Send raw GAQL query to Google Ads API", + RunE: runDebugGoogleAds, +} + +var ( + debugGoogleCustomerID string + debugGoogleLoginCustomerID string + debugGoogleGAQL string +) + +func init() { + debugGoogleAdsCmd.Flags().StringVar(&debugGoogleCustomerID, "customer-id", "", "Google Ads customer ID (required)") + debugGoogleAdsCmd.Flags().StringVar(&debugGoogleLoginCustomerID, "login-customer-id", "", "MCC login customer ID (optional)") + debugGoogleAdsCmd.Flags().StringVar(&debugGoogleGAQL, "gaql", "", "GAQL query to execute (required)") + debugGoogleAdsCmd.MarkFlagRequired("customer-id") + debugGoogleAdsCmd.MarkFlagRequired("gaql") + debugCmd.AddCommand(debugGoogleAdsCmd) + + debugMetaAdsCmd.Flags().StringVar(&debugMetaAccountID, "account-id", "", "Meta ad account ID, e.g. act_123 (required)") + debugMetaAdsCmd.Flags().StringVar(&debugMetaPath, "path", "", "Graph API path, e.g. act_123/campaigns?fields=id,name (required)") + debugMetaAdsCmd.MarkFlagRequired("account-id") + debugMetaAdsCmd.MarkFlagRequired("path") + debugCmd.AddCommand(debugMetaAdsCmd) + + debugTikTokAdsCmd.Flags().StringVar(&debugTikTokAdvertiserID, "advertiser-id", "", "TikTok advertiser ID (required)") + debugTikTokAdsCmd.Flags().StringVar(&debugTikTokEndpoint, "endpoint", "", "TikTok API endpoint path (required)") + debugTikTokAdsCmd.Flags().StringVar(&debugTikTokParams, "params", "{}", "JSON params for the request") + debugTikTokAdsCmd.MarkFlagRequired("advertiser-id") + debugTikTokAdsCmd.MarkFlagRequired("endpoint") + debugCmd.AddCommand(debugTikTokAdsCmd) +} + +func runDebugGoogleAds(cmd *cobra.Command, args []string) error { + token, err := getToken() + if err != nil { + return err + } + + c := client.New(apiURL, token) + + params := map[string]interface{}{ + "customer_id": debugGoogleCustomerID, + "gaql": debugGoogleGAQL, + } + if debugGoogleLoginCustomerID != "" { + params["login_customer_id"] = debugGoogleLoginCustomerID + } + + result, err := c.CallTool("google-ads-mcp", "debug_query", params) + if err != nil { + return fmt.Errorf("debug query failed: %w", err) + } + + return printJSON(result) +} + +// --- Meta Ads debug --- + +var debugMetaAdsCmd = &cobra.Command{ + Use: "meta-ads", + Short: "Send raw Graph API query to Meta Ads API", + RunE: runDebugMetaAds, +} + +var ( + debugMetaAccountID string + debugMetaPath string +) + +func runDebugMetaAds(cmd *cobra.Command, args []string) error { + token, err := getToken() + if err != nil { + return err + } + + c := client.New(apiURL, token) + + params := map[string]interface{}{ + "account_id": debugMetaAccountID, + "path": debugMetaPath, + } + + result, err := c.CallTool("meta-ads-mcp", "debug_query", params) + if err != nil { + return fmt.Errorf("debug query failed: %w", err) + } + + return printJSON(result) +} + +// --- TikTok Ads debug --- + +var debugTikTokAdsCmd = &cobra.Command{ + Use: "tiktok-ads", + Short: "Send raw API query to TikTok Ads API", + RunE: runDebugTikTokAds, +} + +var ( + debugTikTokAdvertiserID string + debugTikTokEndpoint string + debugTikTokParams string +) + +func runDebugTikTokAds(cmd *cobra.Command, args []string) error { + token, err := getToken() + if err != nil { + return err + } + + c := client.New(apiURL, token) + + var parsedParams map[string]interface{} + if err := json.Unmarshal([]byte(debugTikTokParams), &parsedParams); err != nil { + return fmt.Errorf("invalid JSON in --params: %w", err) + } + + params := map[string]interface{}{ + "advertiser_id": debugTikTokAdvertiserID, + "endpoint": debugTikTokEndpoint, + "params": parsedParams, + } + + result, err := c.CallTool("tiktok-ads-mcp", "debug_query", params) + if err != nil { + return fmt.Errorf("debug query failed: %w", err) + } + + return printJSON(result) +} + +func printJSON(v interface{}) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 0000000..4ac98e9 --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Authenticate with Pipeboard via browser OAuth", + Long: `Opens your browser to authenticate with Pipeboard. +After completing the OAuth flow, your token is stored in ~/.pipeboard/config.json. + +Alternative: set the PIPEBOARD_API_TOKEN environment variable.`, + RunE: func(cmd *cobra.Command, args []string) error { + // TODO: Implement browser OAuth flow + // 1. Start local HTTP server on random port + // 2. Open browser to pipeboard.co/cli/login?callback=http://localhost: + // 3. Wait for callback with token + // 4. Store token in config + fmt.Println("Browser-based login is not yet implemented.") + fmt.Println("For now, use one of these methods:") + fmt.Println() + fmt.Println(" export PIPEBOARD_API_TOKEN=") + fmt.Println(" pipeboard config set token ") + fmt.Println() + fmt.Println("Get your API token at: https://pipeboard.co/settings") + return nil + }, +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..8910695 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var ( + // Version is set at build time via ldflags. + Version = "dev" + // Commit is set at build time via ldflags. + Commit = "none" + + apiURL string +) + +var rootCmd = &cobra.Command{ + Use: "pipeboard", + Short: "Manage ads across Meta, Google, and TikTok from your terminal", + Long: `pipeboard is a command-line tool for managing advertising campaigns +across Meta Ads, Google Ads, and TikTok Ads. + +All operations go through Pipeboard servers. Authenticate with: + - PIPEBOARD_API_TOKEN environment variable + - pipeboard login (browser-based OAuth) + - pipeboard config set token `, +} + +func Execute() error { + return rootCmd.Execute() +} + +func init() { + rootCmd.PersistentFlags().StringVar(&apiURL, "api-url", getDefaultAPIURL(), "Pipeboard API base URL") + + rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(loginCmd) + rootCmd.AddCommand(configCmd) + rootCmd.AddCommand(debugCmd) +} + +func getDefaultAPIURL() string { + if url := os.Getenv("PIPEBOARD_API_URL"); url != "" { + return url + } + return "https://mcp.pipeboard.co" +} + +func getToken() (string, error) { + // 1. Check environment variable + if token := os.Getenv("PIPEBOARD_API_TOKEN"); token != "" { + return token, nil + } + + // 2. Check config file + cfg, err := loadConfig() + if err == nil && cfg.Token != "" { + return cfg.Token, nil + } + + return "", fmt.Errorf("not authenticated. Run 'pipeboard login' or set PIPEBOARD_API_TOKEN") +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..6ccc5ea --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,15 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("pipeboard %s (commit: %s)\n", Version, Commit) + }, +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5d4dd52 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/pipeboard-co/pipeboard-cli + +go 1.23 + +require github.com/spf13/cobra v1.9.1 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ffae55e --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/mcp.go b/internal/client/mcp.go new file mode 100644 index 0000000..3939250 --- /dev/null +++ b/internal/client/mcp.go @@ -0,0 +1,202 @@ +// Package client implements an MCP JSON-RPC client for Pipeboard servers. +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sync/atomic" +) + +// Client talks to Pipeboard MCP servers over HTTP. +type Client struct { + baseURL string + token string + httpClient *http.Client + reqID atomic.Int64 +} + +// New creates an MCP client. +func New(baseURL, token string) *Client { + return &Client{ + baseURL: baseURL, + token: token, + httpClient: &http.Client{}, + } +} + +// jsonRPCRequest is a JSON-RPC 2.0 request. +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +// jsonRPCResponse is a JSON-RPC 2.0 response. +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *jsonRPCError) Error() string { + return fmt.Sprintf("MCP error %d: %s", e.Code, e.Message) +} + +// ToolDefinition represents a tool from tools/list. +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` +} + +// ToolsListResult is the result of tools/list. +type ToolsListResult struct { + Tools []ToolDefinition `json:"tools"` +} + +// Initialize performs the MCP handshake with a server. +func (c *Client) Initialize(serverPath string) error { + params := map[string]interface{}{ + "protocolVersion": "2025-03-26", + "capabilities": map[string]interface{}{}, + "clientInfo": map[string]string{ + "name": "pipeboard-cli", + "version": "0.1.0", + }, + } + + _, err := c.call(serverPath, "initialize", params) + if err != nil { + return fmt.Errorf("initialize: %w", err) + } + + // Send initialized notification (no response expected) + _ = c.notify(serverPath, "notifications/initialized", nil) + + return nil +} + +// ListTools fetches the list of tools from an MCP server. +func (c *Client) ListTools(serverPath string) (*ToolsListResult, error) { + raw, err := c.call(serverPath, "tools/list", nil) + if err != nil { + return nil, fmt.Errorf("tools/list: %w", err) + } + + var result ToolsListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("parsing tools/list result: %w", err) + } + return &result, nil +} + +// CallTool invokes a tool on an MCP server and returns the raw result. +func (c *Client) CallTool(serverPath, toolName string, arguments map[string]interface{}) (json.RawMessage, error) { + params := map[string]interface{}{ + "name": toolName, + "arguments": arguments, + } + + raw, err := c.call(serverPath, "tools/call", params) + if err != nil { + return nil, fmt.Errorf("tools/call %s: %w", toolName, err) + } + return raw, nil +} + +func (c *Client) call(serverPath, method string, params interface{}) (json.RawMessage, error) { + id := c.reqID.Add(1) + + req := jsonRPCRequest{ + JSONRPC: "2.0", + ID: id, + Method: method, + Params: params, + } + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + url := fmt.Sprintf("%s/%s", c.baseURL, serverPath) + httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.token) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + var rpcResp jsonRPCResponse + if err := json.Unmarshal(respBody, &rpcResp); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + if rpcResp.Error != nil { + return nil, rpcResp.Error + } + + return rpcResp.Result, nil +} + +func (c *Client) notify(serverPath, method string, params interface{}) error { + // Notifications have no ID and expect no response. + type notification struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` + } + + req := notification{ + JSONRPC: "2.0", + Method: method, + Params: params, + } + + body, err := json.Marshal(req) + if err != nil { + return err + } + + url := fmt.Sprintf("%s/%s", c.baseURL, serverPath) + httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return err + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.token) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return err + } + resp.Body.Close() + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9e22708 --- /dev/null +++ b/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + + "github.com/pipeboard-co/pipeboard-cli/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +}