diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..da0a80c0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,89 @@ +name: Build and Release Custom Plugins + +on: + push: + branches: + - main + paths: + - 'plugins/restic/**' + - 'plugins/llm/**' + - '.github/workflows/release.yml' + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + plugin: [restic, llm] + goos: [linux, darwin] + goarch: [amd64, arm64] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Generate registry + run: go run cmd/contrib/main.go registry + + - name: Build plugin + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + mkdir -p dist + go build -o dist/${{ matrix.plugin }}-${{ matrix.goos }}-${{ matrix.goarch }} \ + -ldflags="-X 'main.PluginName=${{ matrix.plugin }}'" \ + ./cmd/contrib/build/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.plugin }}-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/${{ matrix.plugin }}-${{ matrix.goos }}-${{ matrix.goarch }} + + release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Generate release tag + id: tag + run: echo "tag=v$(date +'%Y%m%d.%H%M%S')" >> $GITHUB_OUTPUT + + - name: Create Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + gh release create ${{ steps.tag.outputs.tag }} \ + --title "Custom Plugins ${{ steps.tag.outputs.tag }}" \ + --notes "Custom 1Password shell plugins build. + + ## Included Plugins + - **restic** - Backup tool with support for S3, Azure, B2, GCS, and REST backends + - **llm** - Simon Willison's LLM CLI with OpenAI and OpenRouter support + + ## Installation + Download the appropriate binary for your platform and place it in \`~/.config/op/plugins/local/\` + + \`\`\`bash + mkdir -p ~/.config/op/plugins/local + chmod 700 ~/.config/op/plugins/local + # Download and rename to just the plugin name (e.g., 'restic' or 'llm') + \`\`\`" \ + dist/* diff --git a/plugins/llm/credentials.go b/plugins/llm/credentials.go new file mode 100644 index 00000000..738b7acc --- /dev/null +++ b/plugins/llm/credentials.go @@ -0,0 +1,62 @@ +package llm + +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" +) + +// Custom field names for llm-specific fields +const ( + OpenAIAPIKey sdk.FieldName = "OpenAI API Key" + OpenRouterAPIKey sdk.FieldName = "OpenRouter API Key" +) + +func Credentials() schema.CredentialType { + return schema.CredentialType{ + Name: credname.Credentials, + DocsURL: sdk.URL("https://llm.datasette.io/en/stable/setup.html"), + ManagementURL: nil, + Fields: []schema.CredentialField{ + { + Name: OpenAIAPIKey, + MarkdownDescription: "The OpenAI API key used to authenticate to OpenAI models.", + Secret: true, + Optional: true, + Composition: &schema.ValueComposition{ + Length: 51, + Prefix: "sk-", + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: OpenRouterAPIKey, + MarkdownDescription: "The OpenRouter API key used to authenticate to OpenRouter models.", + Secret: true, + Optional: true, + Composition: &schema.ValueComposition{ + Length: 51, + Prefix: "sk-or-", + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + }, + DefaultProvisioner: provision.EnvVars(defaultEnvVarMapping), + Importer: importer.TryEnvVarPair(defaultEnvVarMapping), + } +} + +var defaultEnvVarMapping = map[string]sdk.FieldName{ + "OPENAI_API_KEY": OpenAIAPIKey, + "OPENROUTER_API_KEY": OpenRouterAPIKey, +} diff --git a/plugins/llm/credentials_test.go b/plugins/llm/credentials_test.go new file mode 100644 index 00000000..c6f58c89 --- /dev/null +++ b/plugins/llm/credentials_test.go @@ -0,0 +1,64 @@ +package llm + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" +) + +func TestCredentialsProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, Credentials().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "with OpenAI": { + ItemFields: map[sdk.FieldName]string{ + OpenAIAPIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyzEXAMPLEKEY", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "OPENAI_API_KEY": "sk-1234567890abcdefghijklmnopqrstuvwxyzEXAMPLEKEY", + }, + }, + }, + "with OpenRouter": { + ItemFields: map[sdk.FieldName]string{ + OpenRouterAPIKey: "sk-or-1234567890abcdefghijklmnopqrstuvwxEXAMPLE", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "OPENROUTER_API_KEY": "sk-or-1234567890abcdefghijklmnopqrstuvwxEXAMPLE", + }, + }, + }, + "with both": { + ItemFields: map[sdk.FieldName]string{ + OpenAIAPIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyzEXAMPLEKEY", + OpenRouterAPIKey: "sk-or-1234567890abcdefghijklmnopqrstuvwxEXAMPLE", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "OPENAI_API_KEY": "sk-1234567890abcdefghijklmnopqrstuvwxyzEXAMPLEKEY", + "OPENROUTER_API_KEY": "sk-or-1234567890abcdefghijklmnopqrstuvwxEXAMPLE", + }, + }, + }, + }) +} + +func TestCredentialsImporter(t *testing.T) { + plugintest.TestImporter(t, Credentials().Importer, map[string]plugintest.ImportCase{ + "environment": { + Environment: map[string]string{ + "OPENAI_API_KEY": "sk-1234567890abcdefghijklmnopqrstuvwxyzEXAMPLEKEY", + "OPENROUTER_API_KEY": "sk-or-1234567890abcdefghijklmnopqrstuvwxEXAMPLE", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + OpenAIAPIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyzEXAMPLEKEY", + OpenRouterAPIKey: "sk-or-1234567890abcdefghijklmnopqrstuvwxEXAMPLE", + }, + }, + }, + }, + }) +} diff --git a/plugins/llm/llm.go b/plugins/llm/llm.go new file mode 100644 index 00000000..26154930 --- /dev/null +++ b/plugins/llm/llm.go @@ -0,0 +1,22 @@ +package llm + +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" +) + +func LLMCLI() schema.Executable { + return schema.Executable{ + Name: "LLM CLI", + Runs: []string{"llm"}, + DocsURL: sdk.URL("https://llm.datasette.io/en/stable/"), + NeedsAuth: needsauth.NotForHelpOrVersion(), + Uses: []schema.CredentialUsage{ + { + Name: credname.Credentials, + }, + }, + } +} diff --git a/plugins/llm/plugin.go b/plugins/llm/plugin.go new file mode 100644 index 00000000..b38eb2fb --- /dev/null +++ b/plugins/llm/plugin.go @@ -0,0 +1,22 @@ +package llm + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "llm", + Platform: schema.PlatformInfo{ + Name: "LLM", + Homepage: sdk.URL("https://llm.datasette.io"), + }, + Credentials: []schema.CredentialType{ + Credentials(), + }, + Executables: []schema.Executable{ + LLMCLI(), + }, + } +} diff --git a/plugins/restic/credentials.go b/plugins/restic/credentials.go new file mode 100644 index 00000000..77887eb8 --- /dev/null +++ b/plugins/restic/credentials.go @@ -0,0 +1,225 @@ +package restic + +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" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +// Custom field names for restic-specific fields +const ( + AzureAccountName sdk.FieldName = "Azure Account Name" + AzureAccountKey sdk.FieldName = "Azure Account Key" + AzureAccountSAS sdk.FieldName = "Azure Account SAS" + B2AccountID sdk.FieldName = "B2 Account ID" + B2AccountKey sdk.FieldName = "B2 Account Key" + RESTUsername sdk.FieldName = "REST Username" + RESTPassword sdk.FieldName = "REST Password" + SessionToken sdk.FieldName = "Session Token" +) + +func Credentials() schema.CredentialType { + return schema.CredentialType{ + Name: credname.Credentials, + DocsURL: sdk.URL("https://restic.readthedocs.io/en/latest/040_backup.html"), + ManagementURL: nil, + Fields: []schema.CredentialField{ + // Core - Required + { + Name: fieldname.Password, + MarkdownDescription: "The password used to encrypt/decrypt the restic repository.", + Secret: true, + Composition: &schema.ValueComposition{ + Length: 32, + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + // AWS S3 / Cloudflare R2 / MinIO - Optional + { + Name: fieldname.AccessKeyID, + MarkdownDescription: "The AWS Access Key ID for S3-compatible backends (AWS S3, Cloudflare R2, MinIO).", + Optional: true, + Composition: &schema.ValueComposition{ + Length: 20, + Prefix: "AKIA", + Charset: schema.Charset{ + Uppercase: true, + Digits: true, + }, + }, + }, + { + Name: fieldname.SecretAccessKey, + MarkdownDescription: "The AWS Secret Access Key for S3-compatible backends.", + Secret: true, + Optional: true, + Composition: &schema.ValueComposition{ + Length: 40, + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: SessionToken, + MarkdownDescription: "The AWS Session Token for temporary credentials.", + Secret: true, + Optional: true, + Composition: &schema.ValueComposition{ + Length: 64, + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + // Azure Blob Storage - Optional + { + Name: AzureAccountName, + MarkdownDescription: "The Azure Storage account name.", + Optional: true, + Composition: &schema.ValueComposition{ + Length: 24, + Charset: schema.Charset{ + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: AzureAccountKey, + MarkdownDescription: "The Azure Storage account key.", + Secret: true, + Optional: true, + Composition: &schema.ValueComposition{ + Length: 88, + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: AzureAccountSAS, + MarkdownDescription: "The Azure Storage SAS token.", + Secret: true, + Optional: true, + Composition: &schema.ValueComposition{ + Length: 64, + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + // Backblaze B2 - Optional + { + Name: B2AccountID, + MarkdownDescription: "The Backblaze B2 Account ID.", + Optional: true, + Composition: &schema.ValueComposition{ + Length: 12, + Charset: schema.Charset{ + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: B2AccountKey, + MarkdownDescription: "The Backblaze B2 Application Key.", + Secret: true, + Optional: true, + Composition: &schema.ValueComposition{ + Length: 31, + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + // Google Cloud Storage - Optional + { + Name: fieldname.ProjectID, + MarkdownDescription: "The Google Cloud project ID.", + Optional: true, + Composition: &schema.ValueComposition{ + Length: 20, + Charset: schema.Charset{ + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: fieldname.Credentials, + MarkdownDescription: "The path to Google Cloud application credentials JSON file.", + Optional: true, + }, + // REST Server - Optional + { + Name: RESTUsername, + MarkdownDescription: "The username for REST server authentication.", + Optional: true, + Composition: &schema.ValueComposition{ + Length: 12, + Charset: schema.Charset{ + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: RESTPassword, + MarkdownDescription: "The password for REST server authentication.", + Secret: true, + Optional: true, + Composition: &schema.ValueComposition{ + Length: 24, + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + }, + DefaultProvisioner: provision.EnvVars(defaultEnvVarMapping), + Importer: importer.TryEnvVarPair(defaultEnvVarMapping), + } +} + +var defaultEnvVarMapping = map[string]sdk.FieldName{ + // Core + "RESTIC_PASSWORD": fieldname.Password, + // AWS S3 / Cloudflare R2 / MinIO + "AWS_ACCESS_KEY_ID": fieldname.AccessKeyID, + "AWS_SECRET_ACCESS_KEY": fieldname.SecretAccessKey, + "AWS_SESSION_TOKEN": SessionToken, + // Azure + "AZURE_ACCOUNT_NAME": AzureAccountName, + "AZURE_ACCOUNT_KEY": AzureAccountKey, + "AZURE_ACCOUNT_SAS": AzureAccountSAS, + // Backblaze B2 + "B2_ACCOUNT_ID": B2AccountID, + "B2_ACCOUNT_KEY": B2AccountKey, + // Google Cloud + "GOOGLE_PROJECT_ID": fieldname.ProjectID, + "GOOGLE_APPLICATION_CREDENTIALS": fieldname.Credentials, + // REST Server + "RESTIC_REST_USERNAME": RESTUsername, + "RESTIC_REST_PASSWORD": RESTPassword, +} diff --git a/plugins/restic/credentials_test.go b/plugins/restic/credentials_test.go new file mode 100644 index 00000000..17e77985 --- /dev/null +++ b/plugins/restic/credentials_test.go @@ -0,0 +1,101 @@ +package restic + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestCredentialsProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, Credentials().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "default with AWS S3": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Password: "mysecretpassword", + fieldname.AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + fieldname.SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "RESTIC_PASSWORD": "mysecretpassword", + "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", + "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + }, + }, + "with Azure": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Password: "mysecretpassword", + AzureAccountName: "myaccount", + AzureAccountKey: "myaccountkey", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "RESTIC_PASSWORD": "mysecretpassword", + "AZURE_ACCOUNT_NAME": "myaccount", + "AZURE_ACCOUNT_KEY": "myaccountkey", + }, + }, + }, + "with B2": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Password: "mysecretpassword", + B2AccountID: "myaccountid", + B2AccountKey: "myaccountkey", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "RESTIC_PASSWORD": "mysecretpassword", + "B2_ACCOUNT_ID": "myaccountid", + "B2_ACCOUNT_KEY": "myaccountkey", + }, + }, + }, + "with REST server": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Password: "mysecretpassword", + RESTUsername: "myuser", + RESTPassword: "myrestpassword", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "RESTIC_PASSWORD": "mysecretpassword", + "RESTIC_REST_USERNAME": "myuser", + "RESTIC_REST_PASSWORD": "myrestpassword", + }, + }, + }, + "password only": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Password: "mysecretpassword", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "RESTIC_PASSWORD": "mysecretpassword", + }, + }, + }, + }) +} + +func TestCredentialsImporter(t *testing.T) { + plugintest.TestImporter(t, Credentials().Importer, map[string]plugintest.ImportCase{ + "environment": { + Environment: map[string]string{ + "RESTIC_PASSWORD": "mysecretpassword", + "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", + "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Password: "mysecretpassword", + fieldname.AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + fieldname.SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + }, + }, + }, + }) +} diff --git a/plugins/restic/plugin.go b/plugins/restic/plugin.go new file mode 100644 index 00000000..68f18071 --- /dev/null +++ b/plugins/restic/plugin.go @@ -0,0 +1,22 @@ +package restic + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "restic", + Platform: schema.PlatformInfo{ + Name: "Restic", + Homepage: sdk.URL("https://restic.net"), + }, + Credentials: []schema.CredentialType{ + Credentials(), + }, + Executables: []schema.Executable{ + ResticCLI(), + }, + } +} diff --git a/plugins/restic/restic.go b/plugins/restic/restic.go new file mode 100644 index 00000000..933757b9 --- /dev/null +++ b/plugins/restic/restic.go @@ -0,0 +1,22 @@ +package restic + +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" +) + +func ResticCLI() schema.Executable { + return schema.Executable{ + Name: "Restic CLI", + Runs: []string{"restic"}, + DocsURL: sdk.URL("https://restic.readthedocs.io"), + NeedsAuth: needsauth.NotForHelpOrVersion(), + Uses: []schema.CredentialUsage{ + { + Name: credname.Credentials, + }, + }, + } +}