Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 5 additions & 1 deletion .github/workflows/release-on-tag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,12 @@ jobs:
for arch in amd64 arm64; do
artifact="dist/${BINARY_NAME}-${os}-${arch}.tar.gz"
tmpdir=$(mktemp -d)
# Build main binary
CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath -ldflags="-s -w -X roam-cli/internal/cli.Version=${RELEASE_TAG#${TAG_PREFIX}}" -o "$tmpdir/${BINARY_NAME}" "$BUILD_TARGET"
tar -czf "$artifact" -C "$tmpdir" "$BINARY_NAME"
# Build 1Password shell plugin binary
CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath -ldflags="-s -w" -o "$tmpdir/roamresearch" ./contrib/1password-plugin/
# Archive both binaries
tar -czf "$artifact" -C "$tmpdir" "${BINARY_NAME}" "roamresearch"
rm -rf "$tmpdir"
done
done
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*.exe
*.test
*.out
contrib/1password-plugin/1password-plugin

# Build artifacts
dist/
Expand Down
23 changes: 23 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ OUT_DIR := dist
CMD := ./cmd/roam-cli
GOFLAGS ?= -buildvcs=false

OP_PLUGIN_NAME := roamresearch
OP_PLUGIN_DIR := contrib/1password-plugin
OP_PLUGIN_BIN := $(BIN_DIR)/$(OP_PLUGIN_NAME)

export GOFLAGS

.PHONY: tidy fmt test bdd-test ci build run install clean cross-build help
.PHONY: op-plugin-test op-plugin-build op-plugin-install-local

help:
@echo "Targets:"
Expand All @@ -21,6 +26,10 @@ help:
@echo " make install - install binary to GOPATH/bin"
@echo " make clean - remove build artifacts"
@echo " make cross-build - build darwin/linux amd64/arm64 binaries"
@echo " make op-plugin-test - run 1Password plugin tests"
@echo " make op-plugin-build - build 1Password plugin binary"
@echo " make op-plugin-install-local - build and install plugin locally"


tidy:
go mod tidy
Expand All @@ -40,6 +49,7 @@ ci:
go vet ./...
go test ./... -count=1
go test -tags=bdd ./tests/bdd/... -count=1
cd $(OP_PLUGIN_DIR) && go test ./... -count=1
mkdir -p $(BIN_DIR)
go build -v -o $(BIN_PATH) $(CMD)

Expand All @@ -56,6 +66,19 @@ install:
clean:
rm -rf $(OUT_DIR) $(BIN_DIR)

op-plugin-test:
cd $(OP_PLUGIN_DIR) && go test ./... -count=1

op-plugin-build:
mkdir -p $(BIN_DIR)
cd $(OP_PLUGIN_DIR) && go build -o ../../$(OP_PLUGIN_BIN) .

op-plugin-install-local: op-plugin-build
mkdir -p ~/.op/plugins/local
chmod 700 ~/.op ~/.op/plugins ~/.op/plugins/local
cp $(OP_PLUGIN_BIN) ~/.op/plugins/local/$(OP_PLUGIN_NAME)
chmod 755 ~/.op/plugins/local/$(OP_PLUGIN_NAME)

cross-build: clean
mkdir -p $(OUT_DIR)
GOOS=darwin GOARCH=amd64 go build -o $(OUT_DIR)/$(BINARY_NAME)-darwin-amd64 $(CMD)
Expand Down
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,59 @@ Use 1Password CLI to inject credentials at runtime:

- https://developer.1password.com/docs/service-accounts/use-with-1password-cli

Example:
### Path A: Simple op run (existing)

Inject credentials from a `.env` file on each invocation:

```bash
op run --env-file=.env -- roam-cli status
op run --env-file=.env -- roam-cli get "Page Title"
```

### Path B: 1Password Shell Plugin (recommended)

The shell plugin lets 1Password CLI automatically inject credentials when you run `roam-cli`, without managing `.env` files.

Prerequisites:
- [1Password CLI](https://1password.com/downloads/command-line) installed
- A 1Password item with fields matching the environment variables below

Install the local plugin:

```bash
# Install the 1Password shell plugin binary.
roam-cli onepassword install

# Let 1Password create the shell wrapper.
op plugin init roam-cli

# Reload shell plugin aliases.
source ~/.config/op/plugins.sh

# Run normally.
roam-cli status
```

> `op plugin init roam-cli` only works after `roam-cli onepassword install` has copied the local plugin binary to `~/.op/plugins/local/roamresearch`.

#### Release archives

Starting from the release that includes this feature, each release archive contains both `roam-cli` and `roamresearch` binaries. Extract both to your `PATH` before running `roam-cli onepassword install`.

#### Developer flow

```bash
make build op-plugin-build
./bin/roam-cli onepassword install --from ./bin/roamresearch --force
```

#### Manual shell wrapper

If you prefer not to use `op plugin init`, add this to your shell rc file:

```bash
roam-cli() {
op plugin run -- roam-cli "$@"
}
export OP_PLUGIN_ALIASES_SOURCED=1
```
61 changes: 61 additions & 0 deletions contrib/1password-plugin/credential.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"github.com/1Password/shell-plugins/sdk"
"github.com/1Password/shell-plugins/sdk/importer"
"github.com/1Password/shell-plugins/sdk/provision"
"github.com/1Password/shell-plugins/sdk/schema"
"github.com/1Password/shell-plugins/sdk/schema/credname"
)

const (
fieldToken = sdk.FieldName("Token")
fieldGraph = sdk.FieldName("Graph")
fieldAPIURL = sdk.FieldName("API URL")
fieldTimeoutSeconds = sdk.FieldName("Timeout Seconds")
)

var envVarMapping = map[string]sdk.FieldName{
"ROAM_API_TOKEN": fieldToken,
"ROAM_API_GRAPH": fieldGraph,
"ROAM_API_BASE_URL": fieldAPIURL,
"ROAM_TIMEOUT_SECONDS": fieldTimeoutSeconds,
}

// APIToken returns the credential type for Roam Research API Token.
func APIToken() schema.CredentialType {
return schema.CredentialType{
Name: credname.APIToken,
DocsURL: sdk.URL("https://github.com/Leechael/roam-cli"),
ManagementURL: sdk.URL("https://roamresearch.com/#/app/roam-cli-settings"),

Fields: []schema.CredentialField{
{
Name: fieldToken,
MarkdownDescription: "Roam Research API token used to authenticate requests.",
Secret: true,
},
{
Name: fieldGraph,
MarkdownDescription: "Roam Research graph name.",
Secret: false,
},
{
Name: fieldAPIURL,
MarkdownDescription: "Optional custom API base URL.",
Secret: false,
Optional: true,
},
{
Name: fieldTimeoutSeconds,
MarkdownDescription: "Optional request timeout in seconds.",
Secret: false,
Optional: true,
},
},

DefaultProvisioner: provision.EnvVars(envVarMapping),

Importer: importer.TryEnvVarPair(envVarMapping),
}
}
129 changes: 129 additions & 0 deletions contrib/1password-plugin/credential_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package main

import (
"context"
"testing"

"github.com/1Password/shell-plugins/sdk"
)

func TestCredentialTypeName(t *testing.T) {
ct := APIToken()
if string(ct.Name) != "API Token" {
t.Errorf("expected %q, got %q", "API Token", ct.Name)
}
}

func TestCredentialFields(t *testing.T) {
ct := APIToken()

if len(ct.Fields) != 4 {
t.Fatalf("expected 4 fields, got %d", len(ct.Fields))
}

tests := []struct {
name sdk.FieldName
secret bool
optional bool
}{
{fieldToken, true, false},
{fieldGraph, false, false},
{fieldAPIURL, false, true},
{fieldTimeoutSeconds, false, true},
}

for _, tt := range tests {
found := false
for _, f := range ct.Fields {
if f.Name == tt.name {
found = true
if f.Secret != tt.secret {
t.Errorf("field %q: secret = %v, want %v", tt.name, f.Secret, tt.secret)
}
if f.Optional != tt.optional {
t.Errorf("field %q: optional = %v, want %v", tt.name, f.Optional, tt.optional)
}
break
}
}
if !found {
t.Errorf("field %q not found", tt.name)
}
}
}

func TestDefaultProvisionerProducesEnvVars(t *testing.T) {
ct := APIToken()

if ct.DefaultProvisioner == nil {
t.Fatal("DefaultProvisioner is nil")
}

in := sdk.ProvisionInput{
ItemFields: map[sdk.FieldName]string{
fieldToken: "tok_abc",
fieldGraph: "my-graph",
fieldAPIURL: "https://custom.api/graph",
fieldTimeoutSeconds: "15",
},
}

out := sdk.ProvisionOutput{Environment: make(map[string]string)}
ct.DefaultProvisioner.Provision(context.Background(), in, &out)

env := out.Environment
if env["ROAM_API_TOKEN"] != "tok_abc" {
t.Errorf("ROAM_API_TOKEN = %q, want %q", env["ROAM_API_TOKEN"], "tok_abc")
}
if env["ROAM_API_GRAPH"] != "my-graph" {
t.Errorf("ROAM_API_GRAPH = %q, want %q", env["ROAM_API_GRAPH"], "my-graph")
}
if env["ROAM_API_BASE_URL"] != "https://custom.api/graph" {
t.Errorf("ROAM_API_BASE_URL = %q, want %q", env["ROAM_API_BASE_URL"], "https://custom.api/graph")
}
if env["ROAM_TIMEOUT_SECONDS"] != "15" {
t.Errorf("ROAM_TIMEOUT_SECONDS = %q, want %q", env["ROAM_TIMEOUT_SECONDS"], "15")
}
}

func TestDefaultProvisionerIsEnvVars(t *testing.T) {
ct := APIToken()
if ct.DefaultProvisioner == nil {
t.Fatal("DefaultProvisioner is nil")
}
desc := ct.DefaultProvisioner.Description()
if desc == "" {
t.Error("DefaultProvisioner description is empty")
}
}

func TestImporterImportsEnvVars(t *testing.T) {
ct := APIToken()

t.Setenv("ROAM_API_TOKEN", "tok_imported")
t.Setenv("ROAM_API_GRAPH", "imported-graph")
t.Setenv("ROAM_API_BASE_URL", "https://imported.api/graph")
t.Setenv("ROAM_TIMEOUT_SECONDS", "42")

out := sdk.ImportOutput{}
ct.Importer(context.Background(), sdk.ImportInput{}, &out)

candidates := out.AllCandidates()
if len(candidates) == 0 {
t.Fatal("expected at least 1 import candidate")
}

cand := candidates[0]
if cand.Fields[fieldToken] != "tok_imported" {
t.Errorf("Token = %q, want %q", cand.Fields[fieldToken], "tok_imported")
}
if cand.Fields[fieldGraph] != "imported-graph" {
t.Errorf("Graph = %q, want %q", cand.Fields[fieldGraph], "imported-graph")
}
if cand.Fields[fieldAPIURL] != "https://imported.api/graph" {
t.Errorf("API URL = %q, want %q", cand.Fields[fieldAPIURL], "https://imported.api/graph")
}
if cand.Fields[fieldTimeoutSeconds] != "42" {
t.Errorf("Timeout Seconds = %q, want %q", cand.Fields[fieldTimeoutSeconds], "42")
}
}
24 changes: 24 additions & 0 deletions contrib/1password-plugin/executable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package main

import (
"github.com/1Password/shell-plugins/sdk"
"github.com/1Password/shell-plugins/sdk/needsauth"
"github.com/1Password/shell-plugins/sdk/schema"
"github.com/1Password/shell-plugins/sdk/schema/credname"
)

// RoamCLI returns the executable schema for the roam-cli binary.
func RoamCLI() schema.Executable {
return schema.Executable{
Name: "Roam Research CLI",
Runs: []string{"roam-cli"},
DocsURL: sdk.URL("https://github.com/Leechael/roam-cli"),
NeedsAuth: needsauth.IfAll(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
needsauth.NotForHelpOrVersion(),
needsauth.NotWithoutArgs(),
),
Uses: []schema.CredentialUsage{
{Name: credname.APIToken},
},
}
}
27 changes: 27 additions & 0 deletions contrib/1password-plugin/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module github.com/Leechael/roam-cli/contrib/1password-plugin

go 1.25.7
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

require (
github.com/1Password/shell-plugins v0.0.0-20260604224627-af9327a7375e
github.com/hashicorp/go-plugin v1.8.0
)

require (
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/oklog/run v1.1.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/grpc v1.61.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
Loading
Loading