Compare commits

..

8 Commits

Author SHA1 Message Date
blink-so[bot] 443db3b7dc Add MCP and force mode support to cursor-cli module
- Add MCP (Model Context Protocol) configuration options
- Add force mode for non-interactive automation
- Add default model selection
- Add rules system configuration
- Update install script to configure MCP and rules
- Update start script with environment variables
- Add comprehensive Coder Tasks integration examples
- Add configuration variables table
- Add screenshot section placeholder
- Update terminal usage examples with force mode

Features added:
- enable_mcp: Enable/disable MCP support
- mcp_config_path: Custom MCP configuration file path
- enable_force_mode: Enable force mode for automation
- default_model: Set default AI model
- enable_rules: Enable rules system

Co-authored-by: matifali <10648092+matifali@users.noreply.github.com>
2025-08-08 11:56:14 +00:00
blink-so[bot] 057d40554b Fix formatting for scripts/terraform_test_all.sh
This file was added to main branch and has formatting issues
that cause CI to fail on merge commits.

Co-authored-by: matifali <10648092+matifali@users.noreply.github.com>
2025-08-08 11:49:02 +00:00
blink-so[bot] 0d73bb6588 Remove scripts directory from cursor module
Co-authored-by: matifali <10648092+matifali@users.noreply.github.com>
2025-08-08 11:47:18 +00:00
blink-so[bot] 2932fb482b Completely restore cursor module to original state
- Undo all changes to existing cursor module
- Keep only the new cursor-cli module
- Ensure backward compatibility

Co-authored-by: matifali <10648092+matifali@users.noreply.github.com>
2025-08-08 11:46:56 +00:00
blink-so[bot] 6ce61c9acd Restore original cursor module README
Co-authored-by: matifali <10648092+matifali@users.noreply.github.com>
2025-08-08 11:43:59 +00:00
blink-so[bot] 6677432df7 Add new cursor-cli module
- Create separate cursor-cli module instead of modifying existing cursor module
- Add AgentAPI integration for web interface and CLI support
- Support both interactive and non-interactive modes
- Include installation and start scripts for cursor-agent
- Comprehensive documentation with CLI usage examples
- Add tests for CLI functionality
- Configure interactive mode with text output

Co-authored-by: matifali <10648092+matifali@users.noreply.github.com>
2025-08-08 11:42:37 +00:00
blink-so[bot] 142167f9c0 Fix formatting issues in cursor README
Co-authored-by: matifali <10648092+matifali@users.noreply.github.com>
2025-08-08 11:38:39 +00:00
blink-so[bot] 798cb1d79c Add Cursor CLI support to cursor module
- Add AgentAPI integration similar to goose module
- Support both interactive and non-interactive modes
- Include installation and start scripts for cursor-agent
- Update README with comprehensive CLI usage examples
- Add tests for new CLI functionality
- Maintain backward compatibility with desktop app
- Configure interactive mode with text output

Co-authored-by: matifali <10648092+matifali@users.noreply.github.com>
2025-08-08 11:37:08 +00:00
66 changed files with 1109 additions and 2332 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@v1.35.3
uses: crate-ci/typos@v1.34.0
with:
config: .github/typos.toml
validate-readme-files:
+1 -2
View File
@@ -14,7 +14,6 @@ on:
paths:
- ".github/workflows/deploy-registry.yaml"
- "registry/**/templates/**"
- "registry/**/README.md"
- ".icons/**"
jobs:
@@ -35,7 +34,7 @@ jobs:
workload_identity_provider: projects/309789351055/locations/global/workloadIdentityPools/github-actions/providers/github
service_account: registry-v2-github@coder-registry-1.iam.gserviceaccount.com
- name: Set up Google Cloud SDK
uses: google-github-actions/setup-gcloud@cb1e50a9932213ecece00a606661ae9ca44f3397
uses: google-github-actions/setup-gcloud@6a7c903a70c8625ed6700fa299f5ddb4ca6022e9
- name: Deploy to dev.registry.coder.com
run: gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch main
- name: Deploy to registry.coder.com
+13 -18
View File
@@ -24,7 +24,7 @@ The Coder Registry is a collection of Terraform modules and templates for Coder
### Install Dependencies
Install Bun (for formatting and scripts):
Install Bun:
```bash
curl -fsSL https://bun.sh/install | bash
@@ -89,7 +89,7 @@ Create `registry/[your-username]/README.md`:
---
display_name: "Your Name"
bio: "Brief description of who you are and what you do"
avatar: "./.images/avatar.png"
avatar_url: "./.images/avatar.png"
github: "your-username"
linkedin: "https://www.linkedin.com/in/your-username" # Optional
website: "https://yourwebsite.com" # Optional
@@ -102,7 +102,7 @@ status: "community"
Brief description of who you are and what you do.
```
> **Note**: The `avatar` must point to `./.images/avatar.png` or `./.images/avatar.svg`.
> **Note**: The `avatar_url` must point to `./.images/avatar.png` or `./.images/avatar.svg`.
### 2. Generate Module Files
@@ -124,23 +124,19 @@ This script generates:
- Accurate description and usage examples
- Correct icon path (usually `../../../../.icons/your-icon.svg`)
- Proper tags that describe your module
3. **Create at least one `.tftest.hcl`** to test your module with `terraform test`
3. **Create `main.test.ts`** to test your module
4. **Add any scripts** or additional files your module needs
### 4. Test and Submit
```bash
# Test your module (from the module directory)
terraform init -upgrade
terraform test -verbose
# Or run all tests in the repo
./scripts/terraform_test_all.sh
# Test your module
bun test -t 'module-name'
# Format code
bun run fmt
bun fmt
# Commit and create PR (do not push to main directly)
# Commit and create PR
git add .
git commit -m "Add [module-name] module"
git push origin your-branch
@@ -339,12 +335,11 @@ coder templates push test-[template-name] -d .
### 2. Test Your Changes
```bash
# Test a specific module (from the module directory)
terraform init -upgrade
terraform test -verbose
# Test a specific module
bun test -t 'module-name'
# Test all modules
./scripts/terraform_test_all.sh
bun test
```
### 3. Maintain Backward Compatibility
@@ -393,7 +388,7 @@ Example: `https://github.com/coder/registry/compare/main...your-branch?template=
### Every Module Must Have
- `main.tf` - Terraform code
- One or more `.tftest.hcl` files - Working tests with `terraform test`
- `main.test.ts` - Working tests
- `README.md` - Documentation with frontmatter
### Every Template Must Have
@@ -493,6 +488,6 @@ When reporting bugs, include:
2. **No tests** or broken tests
3. **Hardcoded values** instead of variables
4. **Breaking changes** without defaults
5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`) before submitting
5. **Not running** `bun fmt` before submitting
Happy contributing! 🚀
+3 -3
View File
@@ -18,9 +18,9 @@ sudo apt install golang-go
Check that PRs have:
- [ ] All required files (`main.tf`, `README.md`, at least one `.tftest.hcl`)
- [ ] All required files (`main.tf`, `main.test.ts`, `README.md`)
- [ ] Proper frontmatter in README
- [ ] Working tests (`terraform test`)
- [ ] Working tests (`bun test`)
- [ ] Formatted code (`bun run fmt`)
- [ ] Avatar image for new namespaces (`avatar.png` or `avatar.svg` in `.images/`)
@@ -127,7 +127,7 @@ tags: ["tag1", "tag2"]
```yaml
display_name: "Your Name"
bio: "Brief description of who you are and what you do"
avatar: "./.images/avatar.png"
avatar_url: "./.images/avatar.png"
github: "username"
linkedin: "https://www.linkedin.com/in/username" # Optional
website: "https://yourwebsite.com" # Optional
-143
View File
@@ -1,143 +0,0 @@
package main
import (
"bufio"
"context"
"strings"
"golang.org/x/xerrors"
)
func validateCoderModuleReadmeBody(body string) []error {
var errs []error
trimmed := strings.TrimSpace(body)
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
errs = append(errs, baseErrs...)
}
foundParagraph := false
terraformCodeBlockCount := 0
foundTerraformVersionRef := false
lineNum := 0
isInsideCodeBlock := false
isInsideTerraform := false
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
for lineScanner.Scan() {
lineNum++
nextLine := lineScanner.Text()
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
// need to check deeper if the first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
}
continue
}
if strings.HasPrefix(nextLine, "```") {
isInsideCodeBlock = !isInsideCodeBlock
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
if isInsideTerraform {
terraformCodeBlockCount++
}
if strings.HasPrefix(nextLine, "```hcl") {
errs = append(errs, xerrors.New("all hcl code blocks must be converted to tf"))
}
continue
}
if isInsideCodeBlock {
if isInsideTerraform {
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
}
continue
}
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
break
}
// Code assumes that if we've reached this point, the only other options are:
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
}
if terraformCodeBlockCount == 0 {
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
} else {
if terraformCodeBlockCount > 1 {
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
}
if !foundTerraformVersionRef {
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
}
}
if !foundParagraph {
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
}
if isInsideCodeBlock {
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
}
return errs
}
func validateCoderModuleReadme(rm coderResourceReadme) []error {
var errs []error
for _, err := range validateCoderModuleReadmeBody(rm.body) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
errs = append(errs, fmErrs...)
}
return errs
}
func validateAllCoderModuleReadmes(resources []coderResourceReadme) error {
var yamlValidationErrors []error
for _, readme := range resources {
errs := validateCoderModuleReadme(readme)
if len(errs) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errs...)
}
}
if len(yamlValidationErrors) != 0 {
return validationPhaseError{
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
return nil
}
func validateAllCoderModules() error {
const resourceType = "modules"
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
if err != nil {
return err
}
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
err = validateAllCoderModuleReadmes(resources)
if err != nil {
return err
}
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
if err := validateCoderResourceRelativeURLs(resources); err != nil {
return err
}
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
return nil
}
+150 -78
View File
@@ -1,6 +1,8 @@
package main
import (
"bufio"
"context"
"errors"
"net/url"
"os"
@@ -15,7 +17,6 @@ import (
var (
supportedResourceTypes = []string{"modules", "templates"}
operatingSystems = []string{"windows", "macos", "linux"}
// TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but
// realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's
@@ -24,21 +25,11 @@ var (
)
type coderResourceFrontmatter struct {
Description string `yaml:"description"`
IconURL string `yaml:"icon"`
DisplayName *string `yaml:"display_name"`
Verified *bool `yaml:"verified"`
Tags []string `yaml:"tags"`
OperatingSystems []string `yaml:"supported_os"`
}
// A slice version of the struct tags from coderResourceFrontmatter. Might be worth using reflection to generate this
// list at runtime in the future, but this should be okay for now
var supportedCoderResourceStructKeys = []string{
"description", "icon", "display_name", "verified", "tags", "supported_os",
// TODO: This is an old, officially deprecated key from the archived coder/modules repo. We can remove this once we
// make sure that the Registry Server is no longer checking this field.
"maintainer_github",
Description string `yaml:"description"`
IconURL string `yaml:"icon"`
DisplayName *string `yaml:"display_name"`
Verified *bool `yaml:"verified"`
Tags []string `yaml:"tags"`
}
// coderResourceReadme represents a README describing a Terraform resource used
@@ -51,17 +42,6 @@ type coderResourceReadme struct {
frontmatter coderResourceFrontmatter
}
func validateSupportedOperatingSystems(systems []string) []error {
var errs []error
for _, s := range systems {
if slices.Contains(operatingSystems, s) {
continue
}
errs = append(errs, xerrors.Errorf("detected unknown operating system %q", s))
}
return errs
}
func validateCoderResourceDisplayName(displayName *string) error {
if displayName != nil && *displayName == "" {
return xerrors.New("if defined, display_name must not be empty string")
@@ -87,7 +67,7 @@ func validateCoderResourceIconURL(iconURL string) []error {
return []error{xerrors.New("icon URL cannot be empty")}
}
var errs []error
errs := []error{}
// If the URL does not have a relative path.
if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") {
@@ -118,7 +98,7 @@ func validateCoderResourceTags(tags []string) error {
// All of these tags are used for the module/template filter controls in the Registry site. Need to make sure they
// can all be placed in the browser URL without issue.
var invalidTags []string
invalidTags := []string{}
for _, t := range tags {
if t != url.QueryEscape(t) {
invalidTags = append(invalidTags, t)
@@ -131,50 +111,119 @@ func validateCoderResourceTags(tags []string) error {
return nil
}
func validateCoderResourceFrontmatter(resourceType string, filePath string, fm coderResourceFrontmatter) []error {
if !slices.Contains(supportedResourceTypes, resourceType) {
return []error{xerrors.Errorf("cannot process unknown resource type %q", resourceType)}
}
func validateCoderResourceReadmeBody(body string) []error {
var errs []error
if err := validateCoderResourceDisplayName(fm.DisplayName); err != nil {
errs = append(errs, addFilePathToError(filePath, err))
}
if err := validateCoderResourceDescription(fm.Description); err != nil {
errs = append(errs, addFilePathToError(filePath, err))
}
if err := validateCoderResourceTags(fm.Tags); err != nil {
errs = append(errs, addFilePathToError(filePath, err))
trimmed := strings.TrimSpace(body)
// TODO: this may cause unexpected behavior since the errors slice may have a 0 length. Add a test.
errs = append(errs, validateReadmeBody(trimmed)...)
foundParagraph := false
terraformCodeBlockCount := 0
foundTerraformVersionRef := false
lineNum := 0
isInsideCodeBlock := false
isInsideTerraform := false
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
for lineScanner.Scan() {
lineNum++
nextLine := lineScanner.Text()
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
// need to check deeper if the first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
}
continue
}
if strings.HasPrefix(nextLine, "```") {
isInsideCodeBlock = !isInsideCodeBlock
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
if isInsideTerraform {
terraformCodeBlockCount++
}
if strings.HasPrefix(nextLine, "```hcl") {
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
}
continue
}
if isInsideCodeBlock {
if isInsideTerraform {
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
}
continue
}
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
break
}
// Code assumes that if we've reached this point, the only other options are:
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
}
for _, err := range validateCoderResourceIconURL(fm.IconURL) {
errs = append(errs, addFilePathToError(filePath, err))
if terraformCodeBlockCount == 0 {
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
} else {
if terraformCodeBlockCount > 1 {
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
}
if !foundTerraformVersionRef {
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
}
}
for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) {
errs = append(errs, addFilePathToError(filePath, err))
if !foundParagraph {
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
}
if isInsideCodeBlock {
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
}
return errs
}
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, []error) {
fm, body, err := separateFrontmatter(rm.rawText)
if err != nil {
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
func validateCoderResourceReadme(rm coderResourceReadme) []error {
var errs []error
for _, err := range validateCoderResourceReadmeBody(rm.body) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
keyErrs := validateFrontmatterYamlKeys(fm, supportedCoderResourceStructKeys)
if len(keyErrs) != 0 {
var remapped []error
for _, e := range keyErrs {
remapped = append(remapped, addFilePathToError(rm.filePath, e))
}
return coderResourceReadme{}, remapped
if err := validateCoderResourceDisplayName(rm.frontmatter.DisplayName); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if err := validateCoderResourceDescription(rm.frontmatter.Description); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if err := validateCoderResourceTags(rm.frontmatter.Tags); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
return errs
}
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, error) {
fm, body, err := separateFrontmatter(rm.rawText)
if err != nil {
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
}
yml := coderResourceFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
}
return coderResourceReadme{
@@ -185,17 +234,13 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead
}, nil
}
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) ([]coderResourceReadme, error) {
if !slices.Contains(supportedResourceTypes, resourceType) {
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
}
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) {
resources := map[string]coderResourceReadme{}
var yamlParsingErrs []error
for _, rm := range rms {
p, errs := parseCoderResourceReadme(resourceType, rm)
if len(errs) != 0 {
yamlParsingErrs = append(yamlParsingErrs, errs...)
p, err := parseCoderResourceReadme(resourceType, rm)
if err != nil {
yamlParsingErrs = append(yamlParsingErrs, err)
continue
}
@@ -208,27 +253,30 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) ([]coderRe
}
}
var serialized []coderResourceReadme
for _, r := range resources {
serialized = append(serialized, r)
yamlValidationErrors := []error{}
for _, readme := range resources {
errs := validateCoderResourceReadme(readme)
if len(errs) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errs...)
}
}
slices.SortFunc(serialized, func(r1 coderResourceReadme, r2 coderResourceReadme) int {
return strings.Compare(r1.filePath, r2.filePath)
})
return serialized, nil
if len(yamlValidationErrors) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
return resources, nil
}
// Todo: Need to beef up this function by grabbing each image/video URL from
// the body's AST.
func validateCoderResourceRelativeURLs(_ []coderResourceReadme) error {
func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error {
return nil
}
func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
if !slices.Contains(supportedResourceTypes, resourceType) {
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
}
registryFiles, err := os.ReadDir(rootRegistryPath)
if err != nil {
return nil, err
@@ -277,3 +325,27 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
}
return allReadmeFiles, nil
}
func validateAllCoderResourceFilesOfType(resourceType string) error {
if !slices.Contains(supportedResourceTypes, resourceType) {
return xerrors.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
}
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
if err != nil {
return err
}
logger.Info(context.Background(), "rocessing README files", "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
logger.Info(context.Background(), "rocessed README files as valid Coder resources", "num_files", len(resources), "type", resourceType)
if err := validateCoderResourceRelativeURLs(resources); err != nil {
return err
}
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "type", resourceType)
return nil
}
@@ -14,7 +14,7 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) {
t.Run("Parses a valid README body with zero issues", func(t *testing.T) {
t.Parallel()
errs := validateCoderModuleReadmeBody(testBody)
errs := validateCoderResourceReadmeBody(testBody)
for _, e := range errs {
t.Error(e)
}
-119
View File
@@ -1,119 +0,0 @@
package main
import (
"bufio"
"context"
"strings"
"golang.org/x/xerrors"
)
func validateCoderTemplateReadmeBody(body string) []error {
var errs []error
trimmed := strings.TrimSpace(body)
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
errs = append(errs, baseErrs...)
}
var nextLine string
foundParagraph := false
isInsideCodeBlock := false
lineNum := 0
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
for lineScanner.Scan() {
lineNum++
nextLine = lineScanner.Text()
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
// need to check deeper if the first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
}
continue
}
if strings.HasPrefix(nextLine, "```") {
isInsideCodeBlock = !isInsideCodeBlock
if strings.HasPrefix(nextLine, "```hcl") {
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
}
continue
}
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
break
}
// Code assumes that if we've reached this point, the only other options are:
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
}
if !foundParagraph {
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
}
if isInsideCodeBlock {
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
}
return errs
}
func validateCoderTemplateReadme(rm coderResourceReadme) []error {
var errs []error
for _, err := range validateCoderTemplateReadmeBody(rm.body) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if fmErrs := validateCoderResourceFrontmatter("templates", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
errs = append(errs, fmErrs...)
}
return errs
}
func validateAllCoderTemplateReadmes(resources []coderResourceReadme) error {
var yamlValidationErrors []error
for _, readme := range resources {
errs := validateCoderTemplateReadme(readme)
if len(errs) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errs...)
}
}
if len(yamlValidationErrors) != 0 {
return validationPhaseError{
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
return nil
}
func validateAllCoderTemplates() error {
const resourceType = "templates"
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
if err != nil {
return err
}
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
err = validateAllCoderTemplateReadmes(resources)
if err != nil {
return err
}
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
if err := validateCoderResourceRelativeURLs(resources); err != nil {
return err
}
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
return nil
}
+13 -46
View File
@@ -19,16 +19,11 @@ type contributorProfileFrontmatter struct {
Bio string `yaml:"bio"`
ContributorStatus string `yaml:"status"`
AvatarURL *string `yaml:"avatar"`
GithubUsername *string `yaml:"github"`
LinkedinURL *string `yaml:"linkedin"`
WebsiteURL *string `yaml:"website"`
SupportEmail *string `yaml:"support_email"`
}
// A slice version of the struct tags from contributorProfileFrontmatter. Might be worth using reflection to generate
// this list at runtime in the future, but this should be okay for now
var supportedContributorProfileStructKeys = []string{"display_name", "bio", "status", "avatar", "linkedin", "github", "website", "support_email"}
type contributorProfileReadme struct {
frontmatter contributorProfileFrontmatter
namespace string
@@ -55,22 +50,6 @@ func validateContributorLinkedinURL(linkedinURL *string) error {
return nil
}
func validateGithubUsername(username *string) error {
if username == nil {
return nil
}
name := *username
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return xerrors.New("username must have non-whitespace characters")
}
if name != trimmed {
return xerrors.Errorf("username %q has extra whitespace", trimmed)
}
return nil
}
// validateContributorSupportEmail does best effort validation of a contributors email address. We can't 100% validate
// that this is correct without actually sending an email, especially because some contributors are individual developers
// and we don't want to do that on every single run of the CI pipeline. The best we can do is verify the general structure.
@@ -79,7 +58,7 @@ func validateContributorSupportEmail(email *string) []error {
return nil
}
var errs []error
errs := []error{}
username, server, ok := strings.Cut(*email, "@")
if !ok {
@@ -140,7 +119,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
return []error{xerrors.New("avatar URL must be omitted or non-empty string")}
}
var errs []error
errs := []error{}
// Have to use .Parse instead of .ParseRequestURI because this is the one field that's allowed to be a relative URL.
if _, err := url.Parse(*avatarURL); err != nil {
errs = append(errs, xerrors.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
@@ -166,7 +145,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
}
func validateContributorReadme(rm contributorProfileReadme) []error {
var allErrs []error
allErrs := []error{}
if err := validateContributorDisplayName(rm.frontmatter.DisplayName); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
@@ -174,9 +153,6 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
if err := validateContributorLinkedinURL(rm.frontmatter.LinkedinURL); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
}
if err := validateGithubUsername(rm.frontmatter.GithubUsername); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
}
if err := validateContributorWebsite(rm.frontmatter.WebsiteURL); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
}
@@ -194,24 +170,15 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
return allErrs
}
func parseContributorProfile(rm readme) (contributorProfileReadme, []error) {
func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
fm, _, err := separateFrontmatter(rm.rawText)
if err != nil {
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
}
keyErrs := validateFrontmatterYamlKeys(fm, supportedContributorProfileStructKeys)
if len(keyErrs) != 0 {
var remapped []error
for _, e := range keyErrs {
remapped = append(remapped, addFilePathToError(rm.filePath, e))
}
return contributorProfileReadme{}, remapped
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
}
yml := contributorProfileFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
}
return contributorProfileReadme{
@@ -223,11 +190,11 @@ func parseContributorProfile(rm readme) (contributorProfileReadme, []error) {
func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfileReadme, error) {
profilesByNamespace := map[string]contributorProfileReadme{}
var yamlParsingErrors []error
yamlParsingErrors := []error{}
for _, rm := range readmeEntries {
p, errs := parseContributorProfile(rm)
if len(errs) != 0 {
yamlParsingErrors = append(yamlParsingErrors, errs...)
p, err := parseContributorProfile(rm)
if err != nil {
yamlParsingErrors = append(yamlParsingErrors, err)
continue
}
@@ -244,7 +211,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
}
}
var yamlValidationErrors []error
yamlValidationErrors := []error{}
for _, p := range profilesByNamespace {
if errors := validateContributorReadme(p); len(errors) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errors...)
@@ -267,8 +234,8 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
return nil, err
}
var allReadmeFiles []readme
var errs []error
allReadmeFiles := []readme{}
errs := []error{}
dirPath := ""
for _, e := range dirEntries {
if !e.IsDir() {
+1 -5
View File
@@ -31,11 +31,7 @@ func main() {
if err != nil {
errs = append(errs, err)
}
err = validateAllCoderModules()
if err != nil {
errs = append(errs, err)
}
err = validateAllCoderTemplates()
err = validateAllCoderResourceFilesOfType("modules")
if err != nil {
errs = append(errs, err)
}
+1 -26
View File
@@ -4,7 +4,6 @@ import (
"bufio"
"fmt"
"regexp"
"slices"
"strings"
"golang.org/x/xerrors"
@@ -40,9 +39,7 @@ const (
var (
supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
// Matches markdown headers placed at the beginning of a line (e.g., "# " or "### "). To make the logic for
// validateReadmeBody easier, this pattern deliberately matches on invalid headers (header levels must be in the
// range 16 to be valid). The function has checks to see if the level is correct.
// Matches markdown headers, must be at the beginning of a line, such as "# " or "### ".
readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`)
)
@@ -171,25 +168,3 @@ func validateReadmeBody(body string) []error {
return errs
}
func validateFrontmatterYamlKeys(frontmatter string, allowedKeys []string) []error {
if len(allowedKeys) == 0 {
return []error{xerrors.New("Set of allowed keys is empty")}
}
var key string
var cutOk bool
var line string
var errs []error
lineScanner := bufio.NewScanner(strings.NewReader(frontmatter))
for lineScanner.Scan() {
line = lineScanner.Text()
key, _, cutOk = strings.Cut(line, ":")
if !cutOk || slices.Contains(allowedKeys, key) {
continue
}
errs = append(errs, xerrors.Errorf("detected unknown key %q", key))
}
return errs
}
+21 -32
View File
@@ -10,21 +10,18 @@ import (
"golang.org/x/xerrors"
)
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images")
// validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all
// expected file conventions
func validateCoderResourceSubdirectory(dirPath string) []error {
resourceDir, err := os.Stat(dirPath)
subDir, err := os.Stat(dirPath)
if err != nil {
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow
// specific rules.
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow specific rules.
if !errors.Is(err, os.ErrNotExist) {
return []error{addFilePathToError(dirPath, err)}
}
}
if !resourceDir.IsDir() {
if !subDir.IsDir() {
return []error{xerrors.Errorf("%q: path is not a directory", dirPath)}
}
@@ -33,11 +30,10 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
return []error{addFilePathToError(dirPath, err)}
}
var errs []error
errs := []error{}
for _, f := range files {
// The .coder subdirectories are sometimes generated as part of our Bun tests. These subdirectories will never
// be committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over
// them.
// The .coder subdirectories are sometimes generated as part of Bun tests. These subdirectories will never be
// committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over them.
if !f.IsDir() || f.Name() == ".coder" {
continue
}
@@ -63,53 +59,49 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
return errs
}
// validateRegistryDirectory validates that the contents of `/registry` follow all expected file conventions. This
// includes the top-level structure of the individual namespace directories.
func validateRegistryDirectory() []error {
namespaceDirs, err := os.ReadDir(rootRegistryPath)
userDirs, err := os.ReadDir(rootRegistryPath)
if err != nil {
return []error{err}
}
var allErrs []error
for _, nDir := range namespaceDirs {
namespacePath := path.Join(rootRegistryPath, nDir.Name())
if !nDir.IsDir() {
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", namespacePath))
allErrs := []error{}
for _, d := range userDirs {
dirPath := path.Join(rootRegistryPath, d.Name())
if !d.IsDir() {
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
continue
}
contributorReadmePath := path.Join(namespacePath, "README.md")
contributorReadmePath := path.Join(dirPath, "README.md")
if _, err := os.Stat(contributorReadmePath); err != nil {
allErrs = append(allErrs, err)
}
files, err := os.ReadDir(namespacePath)
files, err := os.ReadDir(dirPath)
if err != nil {
allErrs = append(allErrs, err)
continue
}
for _, f := range files {
// TODO: Decide if there's anything more formal that we want to ensure about non-directories at the top
// level of each user namespace.
// TODO: Decide if there's anything more formal that we want to ensure about non-directories scoped to user namespaces.
if !f.IsDir() {
continue
}
segment := f.Name()
filePath := path.Join(namespacePath, segment)
filePath := path.Join(dirPath, segment)
if !slices.Contains(supportedUserNameSpaceDirectories, segment) {
allErrs = append(allErrs, xerrors.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", ")))
continue
}
if !slices.Contains(supportedResourceTypes, segment) {
continue
}
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
allErrs = append(allErrs, errs...)
if slices.Contains(supportedResourceTypes, segment) {
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
allErrs = append(allErrs, errs...)
}
}
}
}
@@ -117,9 +109,6 @@ func validateRegistryDirectory() []error {
return allErrs
}
// validateRepoStructure validates that the structure of the repo is "correct enough" to do all necessary validation
// checks. It is NOT an exhaustive validation of the entire repo structure it only checks the parts of the repo that
// are relevant for the main validation steps
func validateRepoStructure() error {
var errs []error
if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 {
-21
View File
@@ -1,21 +0,0 @@
run "plan_with_required_vars" {
command = plan
variables {
agent_id = "example-agent-id"
}
}
run "app_url_uses_port" {
command = plan
variables {
agent_id = "example-agent-id"
port = 19999
}
assert {
condition = resource.coder_app.MODULE_NAME.url == "http://localhost:19999"
error_message = "Expected MODULE_NAME app URL to include configured port"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"fmt": "bun x prettier --write **/*.sh **/*.ts **/*.md *.md && terraform fmt -recursive -diff",
"fmt:ci": "bun x prettier --check **/*.sh **/*.ts **/*.md *.md && terraform fmt -check -recursive -diff",
"terraform-validate": "./scripts/terraform_validate.sh",
"test": "./scripts/terraform_test_all.sh",
"test": "bun test",
"update-version": "./update-version.sh"
},
"devDependencies": {
+1 -1
View File
@@ -1,7 +1,7 @@
---
display_name: "Jay Kumar"
bio: "I'm a Software Engineer :)"
avatar: "./.images/avatar.jpeg"
avatar_url: "./.images/avatar.png"
github: "35C4n0r"
linkedin: "https://www.linkedin.com/in/jaykum4r"
support_email: "work.jaykumar@gmail.com"
+37 -100
View File
@@ -1,41 +1,36 @@
---
display_name: Gemini CLI
description: Run Gemini CLI in your workspace for AI pair programming
icon: ../../../../.icons/gemini.svg
description: Run Gemini CLI in your workspace with AgentAPI integration
verified: true
tags: [agent, gemini, ai, google, tasks]
---
# Gemini CLI
Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace to access Google's Gemini AI models for interactive coding assistance and automated task execution.
Run [Gemini CLI](https://ai.google.dev/gemini-api/docs/cli) in your workspace to access Google's Gemini AI models, and custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility.
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
gemini_model = "gemini-2.5-pro"
install_gemini = true
gemini_version = "latest"
agentapi_version = "latest"
}
```
## Features
- **Interactive AI Assistance**: Run Gemini CLI directly in your terminal for coding help
- **Automated Task Execution**: Execute coding tasks automatically via AgentAPI integration
- **Multiple AI Models**: Support for Gemini 2.5 Pro, Flash, and other Google AI models
- **API Key Integration**: Seamless authentication with Gemini API
- **MCP Server Integration**: Built-in Coder MCP server for task reporting
- **Persistent Sessions**: Maintain context across workspace sessions
## Prerequisites
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login/coder) module to your template
- Node.js and npm will be installed automatically if not present
- The [Coder Login](https://registry.coder.com/modules/coder/coder-login) module is required
## Examples
## Usage Example
### Basic setup
- Example 1:
```tf
variable "gemini_api_key" {
@@ -45,97 +40,39 @@ variable "gemini_api_key" {
}
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key # we recommend providing this parameter inorder to have a smoother experience (i.e. no google sign-in)
gemini_model = "gemini-2.5-flash"
install_gemini = true
gemini_version = "latest"
gemini_instruction_prompt = "Start every response with `Gemini says:`"
}
```
This basic setup will:
## How it Works
- Install Gemini CLI in the workspace
- Configure authentication with your API key
- Set Gemini to run in `/home/coder/project` directory
- Enable interactive use from the terminal
- Set up MCP server integration for task reporting
### Automated task execution (Experimental)
> This functionality is in early access and is still evolving.
> For now, we recommend testing it in a demo or staging environment,
> rather than deploying to production
>
> Learn more in [the Coder documentation](https://coder.com/docs/ai-coder)
```tf
variable "gemini_api_key" {
type = string
description = "Gemini API key"
sensitive = true
}
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/coder-login/coder"
version = "~> 1.0"
agent_id = coder_agent.example.id
}
data "coder_parameter" "ai_prompt" {
type = "string"
name = "AI Prompt"
default = ""
description = "Task prompt for automated Gemini execution"
mutable = true
}
module "gemini" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
gemini_model = "gemini-2.5-flash"
folder = "/home/coder/project"
task_prompt = data.coder_parameter.ai_prompt.value
enable_yolo_mode = true # Auto-approve all tool calls for automation
gemini_system_prompt = <<-EOT
You are a helpful coding assistant. Always explain your code changes clearly.
YOU MUST REPORT ALL TASKS TO CODER.
EOT
}
```
> [!WARNING]
> YOLO mode automatically approves all tool calls without user confirmation. The agent has access to your machine's file system and terminal. Only enable in trusted, isolated environments.
### Using Vertex AI (Enterprise)
For enterprise users who prefer Google's Vertex AI platform:
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
use_vertexai = true
}
```
- **Install**: The module installs Gemini CLI using npm (installs Node.js via NVM if needed)
- **Instruction Prompt**: If `GEMINI_INSTRUCTION_PROMPT` and `GEMINI_START_DIRECTORY` are set, creates the directory (if needed) and writes the prompt to `GEMINI.md`
- **Start**: Launches Gemini CLI in the specified directory, wrapped by AgentAPI
- **Environment**: Sets `GEMINI_API_KEY`, `GOOGLE_GENAI_USE_VERTEXAI`, `GEMINI_MODEL` for the CLI (if variables provided)
## Troubleshooting
- If Gemini CLI is not found, ensure your API key is valid (`install_gemini` defaults to `true`)
- Check logs in `~/.gemini-module/` for install/start output
- Use the `gemini_api_key` variable to avoid requiring Google sign-in
- If Gemini CLI is not found, ensure `install_gemini = true` and your API key is valid
- Node.js and npm are installed automatically if missing (using NVM)
- Check logs in `/home/coder/.gemini-module/` for install/start output
- We highly recommend using the `gemini_api_key` variable, this also ensures smooth tasks running without needing to sign in to Google.
The module creates log files in the workspace's `~/.gemini-module` directory for debugging purposes.
> [!IMPORTANT]
> To use tasks with Gemini CLI, ensure you have the `gemini_api_key` variable set, and **you pass the `AI Prompt` Parameter**.
> By default we inject the "theme": "Default" and "selectedAuthType": "gemini-api-key" to your ~/.gemini/settings.json along with the coder mcp server.
> In `gemini_instruction_prompt` and `AI Prompt` text we recommend using (\`\`) backticks instead of quotes to avoid escaping issues. Eg: gemini_instruction_prompt = "Start every response with \`Gemini says:\` "
## References
- [Gemini CLI Documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/index.md)
- [Gemini CLI Documentation](https://ai.google.dev/gemini-api/docs/cli)
- [AgentAPI Documentation](https://github.com/coder/agentapi)
- [Coder AI Agents Guide](https://coder.com/docs/ai-coder)
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
@@ -8,6 +8,7 @@ import {
} from "bun:test";
import { execContainer, readFileContainer, runTerraformInit } from "~test";
import {
loadTestFile,
writeExecutable,
setup as setupUtil,
execModuleScript,
@@ -53,24 +54,10 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
agentapiMockScript: props?.agentapiMockScript,
});
if (!props?.skipGeminiMock) {
const geminiMockContent = `#!/bin/bash
if [[ "$1" == "--version" ]]; then
echo "HELLO: $(bash -c env)"
echo "gemini version v2.5.0"
exit 0
fi
set -e
while true; do
echo "$(date) - gemini-mock"
sleep 15
done`;
await writeExecutable({
containerId: id,
filePath: "/usr/bin/gemini",
content: geminiMockContent,
content: await loadTestFile(import.meta.dir, "gemini-mock.sh"),
});
}
return { id };
@@ -83,7 +70,7 @@ describe("gemini", async () => {
await runTerraformInit(import.meta.dir);
});
test("agent-api", async () => {
test("happy-path", async () => {
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
@@ -130,7 +117,7 @@ describe("gemini", async () => {
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.gemini-module/agentapi-start.log");
expect(resp).toContain("Using direct Gemini API with API key");
expect(resp).toContain("gemini_api_key provided !");
});
test("use-vertexai", async () => {
@@ -210,20 +197,6 @@ describe("gemini", async () => {
expect(resp).toContain(prompt);
});
test("task-prompt", async () => {
const taskPrompt = "Create a simple Hello World function";
const { id } = await setup({
moduleVariables: {
task_prompt: taskPrompt,
},
});
await execModuleScript(id, {
GEMINI_TASK_PROMPT: taskPrompt,
});
const resp = await readFileContainer(id, "/home/coder/.gemini-module/agentapi-start.log");
expect(resp).toContain("Running automated task:");
});
test("start-without-prompt", async () => {
const { id } = await setup();
await execModuleScript(id);
+25 -36
View File
@@ -74,14 +74,14 @@ variable "use_vertexai" {
variable "install_agentapi" {
type = bool
description = "Whether to install AgentAPI for web UI and task automation."
description = "Whether to install AgentAPI."
default = true
}
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.2.3"
default = "v0.3.0"
}
variable "gemini_model" {
@@ -102,10 +102,12 @@ variable "post_install_script" {
default = null
}
variable "task_prompt" {
type = string
description = "Task prompt for automated Gemini execution"
data "coder_parameter" "ai_prompt" {
type = "string"
name = "AI Prompt"
default = ""
description = "Initial prompt for the Gemini CLI"
mutable = true
}
variable "additional_extensions" {
@@ -120,24 +122,12 @@ variable "gemini_system_prompt" {
default = ""
}
variable "enable_yolo_mode" {
type = bool
description = "Enable YOLO mode to automatically approve all tool calls without user confirmation. Use with caution."
default = false
}
resource "coder_env" "gemini_api_key" {
agent_id = var.agent_id
name = "GEMINI_API_KEY"
value = var.gemini_api_key
}
resource "coder_env" "google_api_key" {
agent_id = var.agent_id
name = "GOOGLE_API_KEY"
value = var.gemini_api_key
}
resource "coder_env" "gemini_use_vertex_ai" {
agent_id = var.agent_id
name = "GOOGLE_GENAI_USE_VERTEXAI"
@@ -176,7 +166,7 @@ EOT
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.1.1"
version = "1.0.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -191,7 +181,22 @@ module "agentapi" {
agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
install_script = <<-EOT
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
chmod +x /tmp/start.sh
GEMINI_API_KEY='${var.gemini_api_key}' \
GOOGLE_GENAI_USE_VERTEXAI='${var.use_vertexai}' \
GEMINI_MODEL='${var.gemini_model}' \
GEMINI_START_DIRECTORY='${var.folder}' \
GEMINI_TASK_PROMPT='${base64encode(data.coder_parameter.ai_prompt.value)}' \
/tmp/start.sh
EOT
install_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
@@ -204,23 +209,7 @@ module "agentapi" {
BASE_EXTENSIONS='${base64encode(replace(local.base_extensions, "'", "'\\''"))}' \
ADDITIONAL_EXTENSIONS='${base64encode(replace(var.additional_extensions != null ? var.additional_extensions : "", "'", "'\\''"))}' \
GEMINI_START_DIRECTORY='${var.folder}' \
GEMINI_SYSTEM_PROMPT='${base64encode(var.gemini_system_prompt)}' \
GEMINI_INSTRUCTION_PROMPT='${base64encode(var.gemini_system_prompt)}' \
/tmp/install.sh
EOT
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
chmod +x /tmp/start.sh
GEMINI_API_KEY='${var.gemini_api_key}' \
GOOGLE_API_KEY='${var.gemini_api_key}' \
GOOGLE_GENAI_USE_VERTEXAI='${var.use_vertexai}' \
GEMINI_YOLO_MODE='${var.enable_yolo_mode}' \
GEMINI_MODEL='${var.gemini_model}' \
GEMINI_START_DIRECTORY='${var.folder}' \
GEMINI_TASK_PROMPT='${var.task_prompt}' \
/tmp/start.sh
EOT
}
@@ -2,6 +2,7 @@
BOLD='\033[0;1m'
# Function to check if a command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
@@ -11,7 +12,7 @@ set -o nounset
ARG_GEMINI_CONFIG=$(echo -n "$ARG_GEMINI_CONFIG" | base64 -d)
BASE_EXTENSIONS=$(echo -n "$BASE_EXTENSIONS" | base64 -d)
ADDITIONAL_EXTENSIONS=$(echo -n "$ADDITIONAL_EXTENSIONS" | base64 -d)
GEMINI_SYSTEM_PROMPT=$(echo -n "$GEMINI_SYSTEM_PROMPT" | base64 -d)
GEMINI_INSTRUCTION_PROMPT=$(echo -n "$GEMINI_INSTRUCTION_PROMPT" | base64 -d)
echo "--------------------------------"
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
@@ -22,6 +23,7 @@ echo "--------------------------------"
set +o nounset
function install_node() {
# borrowed from claude-code module
if ! command_exists npm; then
printf "npm not found, checking for Node.js installation...\n"
if ! command_exists node; then
@@ -50,15 +52,24 @@ function install_node() {
function install_gemini() {
if [ "${ARG_INSTALL}" = "true" ]; then
# we need node to install and run gemini-cli
install_node
# If nvm does not exist, we will create a global npm directory (this os to prevent the possibility of EACCESS issues on npm -g)
if ! command_exists nvm; then
printf "which node: %s\n" "$(which node)"
printf "which npm: %s\n" "$(which npm)"
# Create a directory for global packages
mkdir -p "$HOME"/.npm-global
# Configure npm to use it
npm config set prefix "$HOME/.npm-global"
# Add to PATH for current session
export PATH="$HOME/.npm-global/bin:$PATH"
# Add to shell profile for future sessions
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
fi
@@ -97,6 +108,7 @@ function append_extensions_to_settings_json() {
fi
if [ ! -f "$SETTINGS_PATH" ]; then
printf "%s does not exist. Creating with merged mcpServers structure.\n" "$SETTINGS_PATH"
# If ADDITIONAL_EXTENSIONS is not set or empty, use '{}'
ADD_EXT_JSON='{}'
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
ADD_EXT_JSON="$ADDITIONAL_EXTENSIONS"
@@ -104,7 +116,10 @@ function append_extensions_to_settings_json() {
printf '{"mcpServers":%s}\n' "$(jq -s 'add' <(echo "$BASE_EXTENSIONS") <(echo "$ADD_EXT_JSON"))" > "$SETTINGS_PATH"
fi
# Prepare temp files
TMP_SETTINGS=$(mktemp)
# If ADDITIONAL_EXTENSIONS is not set or empty, use '{}'
ADD_EXT_JSON='{}'
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
printf "[append_extensions_to_settings_json] ADDITIONAL_EXTENSIONS is set.\n"
@@ -118,13 +133,14 @@ function append_extensions_to_settings_json() {
'.mcpServers = (.mcpServers // {} + $base + $add)' \
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
# Add theme and selectedAuthType fields
jq '.theme = "Default" | .selectedAuthType = "gemini-api-key"' "$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
printf "[append_extensions_to_settings_json] Merge complete.\n"
}
function add_system_prompt_if_exists() {
if [ -n "${GEMINI_SYSTEM_PROMPT:-}" ]; then
function add_instruction_prompt_if_exists() {
if [ -n "${GEMINI_INSTRUCTION_PROMPT:-}" ]; then
if [ -d "${GEMINI_START_DIRECTORY}" ]; then
printf "Directory '%s' exists. Changing to it.\\n" "${GEMINI_START_DIRECTORY}"
cd "${GEMINI_START_DIRECTORY}" || {
@@ -144,21 +160,16 @@ function add_system_prompt_if_exists() {
fi
touch GEMINI.md
printf "Setting GEMINI.md\n"
echo "${GEMINI_SYSTEM_PROMPT}" > GEMINI.md
echo "${GEMINI_INSTRUCTION_PROMPT}" > GEMINI.md
else
printf "GEMINI.md is not set.\n"
fi
}
function configure_mcp() {
export CODER_MCP_APP_STATUS_SLUG="gemini"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
coder exp mcp configure gemini "${GEMINI_START_DIRECTORY}"
}
# Install Gemini
install_gemini
gemini --version
populate_settings_json
add_system_prompt_if_exists
configure_mcp
add_instruction_prompt_if_exists
@@ -1,7 +1,6 @@
#!/bin/bash
set -o errexit
set -o pipefail
# Load shell environment
source "$HOME"/.bashrc
command_exists() {
@@ -16,8 +15,7 @@ fi
printf "Version: %s\n" "$(gemini --version)"
MODULE_DIR="$HOME/.gemini-module"
mkdir -p "$MODULE_DIR"
GEMINI_TASK_PROMPT=$(echo -n "$GEMINI_TASK_PROMPT" | base64 -d)
if command_exists gemini; then
printf "Gemini is installed\n"
@@ -45,30 +43,20 @@ else
fi
if [ -n "$GEMINI_TASK_PROMPT" ]; then
printf "Running automated task: %s\n" "$GEMINI_TASK_PROMPT"
printf "Running the task prompt %s\n" "$GEMINI_TASK_PROMPT"
PROMPT="Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GEMINI_TASK_PROMPT"
PROMPT_FILE="$MODULE_DIR/prompt.txt"
echo -n "$PROMPT" >"$PROMPT_FILE"
GEMINI_ARGS=(--prompt-interactive "$PROMPT")
else
printf "Starting Gemini CLI in interactive mode.\n"
printf "No task prompt given.\n"
GEMINI_ARGS=()
fi
if [ -n "$GEMINI_YOLO_MODE" ] && [ "$GEMINI_YOLO_MODE" = "true" ]; then
printf "YOLO mode enabled - will auto-approve all tool calls\n"
GEMINI_ARGS+=(--yolo)
fi
if [ -n "$GEMINI_API_KEY" ] || [ -n "$GOOGLE_API_KEY" ]; then
if [ -n "$GOOGLE_GENAI_USE_VERTEXAI" ] && [ "$GOOGLE_GENAI_USE_VERTEXAI" = "true" ]; then
printf "Using Vertex AI with API key\n"
else
printf "Using direct Gemini API with API key\n"
fi
if [ -n "$GEMINI_API_KEY" ]; then
printf "gemini_api_key provided !\n"
else
printf "No API key provided (neither GEMINI_API_KEY nor GOOGLE_API_KEY)\n"
printf "gemini_api_key not provided\n"
fi
agentapi server --term-width 67 --term-height 1190 -- \
bash -c "$(printf '%q ' gemini "${GEMINI_ARGS[@]}")"
# use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters
# are visible in the terminal screen by default.
agentapi server --term-width 67 --term-height 1190 -- gemini "${GEMINI_ARGS[@]}"
@@ -0,0 +1,14 @@
#!/bin/bash
if [[ "$1" == "--version" ]]; then
echo "HELLO: $(bash -c env)"
echo "gemini version v2.5.0"
exit 0
fi
set -e
while true; do
echo "$(date) - gemini-mock"
sleep 15
done
@@ -8,10 +8,6 @@ tags: [docker, container, dockerfile]
# Remote Development on Docker Containers (Build from Dockerfile)
> [!NOTE]
> This template is designed to be a starting point for testing purposes.
> In a production environment, you would want to move away from storing the Dockerfile in-template and move towards using a centralized image registry.
Build and provision Docker containers from a Dockerfile as [Coder workspaces](https://coder.com/docs/workspaces) with this example template.
This template builds a custom Docker image from the included Dockerfile, allowing you to customize the development environment by modifying the Dockerfile rather than using a pre-built image.
@@ -22,22 +18,7 @@ This template builds a custom Docker image from the included Dockerfile, allowin
### Infrastructure
#### Running Coder inside Docker
If you installed Coder as a container within Docker, you will have to do the following things:
- Make the the Docker socket available to the container
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
- You can get the GID by running `getent group docker` on the **host** machine
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
#### Running Coder outside of Docker
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
```sh
# Add coder user to Docker group
@@ -63,7 +63,7 @@ Visit this URL for your Coder deployment:
https://coder.example.com/templates/new?exampleId=scratch
```
After creating the template, paste the contents from [main.tf](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/tasks-docker/main.tf) into the template editor and save.
After creating the template, paste the contents from [main.tf](./main.tf) into the template editor and save.
Alternatively, you can use the Coder CLI to [push the template](https://coder.com/docs/reference/cli/templates_push)
+1 -1
View File
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
```tf
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.1.1"
version = "1.1.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -236,17 +236,4 @@ describe("agentapi", async () => {
}
}
});
test("agentapi-allowed-hosts", async () => {
// verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable
// set in main.sh
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
const agentApiStartLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
});
});
@@ -95,7 +95,5 @@ export LC_ALL=en_US.UTF-8
cd "${WORKDIR}"
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*"
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &>"$module_path/agentapi-start.log" &
"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
@@ -1,13 +1,11 @@
#!/usr/bin/env node
const http = require("http");
const fs = require("fs");
const args = process.argv.slice(2);
const portIdx = args.findIndex((arg) => arg === "--port") + 1;
const port = portIdx ? args[portIdx] : 3284;
console.log(`starting server on port ${port}`);
fs.writeFileSync("/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`);
http
.createServer(function (_request, response) {
+3 -3
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "2.0.7"
version = "2.0.6"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -84,7 +84,7 @@ resource "coder_agent" "main" {
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
version = "2.0.7"
version = "2.0.6"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -102,7 +102,7 @@ Run Claude Code as a standalone app in your workspace. This will install Claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "2.0.7"
version = "2.0.6"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -10,7 +10,6 @@ import path from "path";
import {
execContainer,
findResourceInstance,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
@@ -320,21 +319,4 @@ describe("claude-code", async () => {
agentApiUrl: "http://localhost:3284",
});
});
// verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable
// set in main.tf
test("agentapi-allowed-hosts", async () => {
const { id } = await setup();
const respModuleScript = await execModuleScript(id);
expect(respModuleScript.exitCode).toBe(0);
await expectAgentAPIStarted(id);
const agentApiStartLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
});
});
@@ -241,10 +241,6 @@ resource "coder_script" "claude_code" {
export LC_ALL=en_US.UTF-8
cd "${local.workdir}"
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*"
nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" &
"$module_path/scripts/agentapi-wait-for-start.sh"
EOT
@@ -20,8 +20,6 @@ if (
process.exit(1);
}
fs.writeFileSync("/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`);
console.log(`starting server on port ${port}`);
http
@@ -1,50 +0,0 @@
run "required_vars" {
command = plan
variables {
agent_id = "foo"
}
}
run "offline_and_use_cached_conflict" {
command = plan
variables {
agent_id = "foo"
use_cached = true
offline = true
}
expect_failures = [
resource.coder_script.code-server
]
}
run "offline_disallows_extensions" {
command = plan
variables {
agent_id = "foo"
offline = true
extensions = ["ms-python.python", "golang.go"]
}
expect_failures = [
resource.coder_script.code-server
]
}
run "url_with_folder_query" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder/project"
port = 13337
}
assert {
condition = resource.coder_app.code-server.url == "http://localhost:13337/?folder=%2Fhome%2Fcoder%2Fproject"
error_message = "coder_app URL must include encoded folder query param"
}
}
+261
View File
@@ -0,0 +1,261 @@
---
display_name: Cursor CLI
description: Run Cursor CLI agent in your workspace with MCP and force mode support
icon: ../../../../.icons/cursor.svg
verified: true
tags: [cli, cursor, ai, agent, mcp, automation]
---
# Cursor CLI
Run the [Cursor CLI](https://docs.cursor.com/en/cli/overview) agent in your workspace for terminal-based AI coding assistance. Supports both interactive and non-interactive modes, MCP (Model Context Protocol), and automation features.
```tf
module "cursor-cli" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor-cli/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
}
```
## Prerequisites
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
## Features
- **CLI Agent**: Terminal-based AI coding assistant with interactive and non-interactive modes
- **AgentAPI Integration**: Web interface for CLI interactions
- **Interactive Mode**: Conversational sessions with text output
- **Non-Interactive Mode**: Automation-friendly for scripts and CI pipelines
- **Session Management**: List, resume, and manage coding sessions
- **Model Selection**: Support for multiple AI models (GPT-5, Claude, etc.)
- **MCP Support**: Model Context Protocol for extended functionality
- **Rules System**: Custom agent behavior configuration
## Examples
### Basic setup
```tf
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/coder-login/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
}
module "cursor-cli" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor-cli/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
install_cursor_cli = true
install_agentapi = true
}
```
### CLI only (no web interface)
```tf
module "cursor-cli" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor-cli/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
install_cursor_cli = true
install_agentapi = false
}
```
### With MCP and force mode for automation
```tf
module "cursor-cli" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor-cli/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
# MCP Configuration
enable_mcp = true
mcp_config_path = "/home/coder/.cursor/custom-mcp.json"
# Automation Features
enable_force_mode = true
default_model = "gpt-5"
# Rules System
enable_rules = true
}
```
### Integration with Coder Tasks
```tf
# Cursor CLI module with automation features
module "cursor-cli" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor-cli/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
enable_force_mode = true
default_model = "claude-4-sonnet"
}
# Automated code review task
resource "coder_task" "ai_code_review" {
agent_id = coder_agent.example.id
name = "AI Code Review"
command = "cursor-agent -p 'review the latest git changes for security issues and best practices' --force --output-format text"
cron = "0 9 * * 1-5" # Weekdays at 9 AM
}
# Automated test generation
resource "coder_task" "generate_tests" {
agent_id = coder_agent.example.id
name = "Generate Missing Tests"
command = "cursor-agent -p 'analyze the src/ directory and generate unit tests for functions missing test coverage' --force"
cron = "0 18 * * *" # Daily at 6 PM
}
# Documentation updates
resource "coder_task" "update_docs" {
agent_id = coder_agent.example.id
name = "Update Documentation"
command = "cursor-agent -p 'review and update README.md to reflect any new features or API changes' --force --model gpt-5"
cron = "0 12 * * 0" # Sundays at noon
}
```
### With custom pre-install script
```tf
module "cursor-cli" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor-cli/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
pre_install_script = <<-EOT
# Install additional dependencies
npm install -g typescript
EOT
}
```
## Usage
### Web Interface
1. Click the "Cursor CLI" button to access the web interface
2. Start interactive sessions with text output
### Terminal Usage
```bash
# Interactive mode (default)
cursor-agent
# Interactive mode with initial prompt
cursor-agent "refactor the auth module to use JWT tokens"
# Non-interactive mode with text output
cursor-agent -p "find and fix performance issues" --output-format text
# Force mode for automation (non-interactive)
cursor-agent -p "review code for security issues" --force
# Use specific model
cursor-agent -p "add error handling" --model "gpt-5"
# Combine force mode with model selection
cursor-agent -p "generate comprehensive tests" --force --model "claude-4-sonnet"
# Session management
cursor-agent ls # List all previous chats
cursor-agent resume # Resume latest conversation
cursor-agent --resume="chat-id" # Resume specific conversation
```
### Interactive Mode Features
- Conversational sessions with the agent
- Review proposed changes before applying
- Real-time guidance and steering
- Text-based output optimized for terminal use
- Session persistence and resumption
### Non-Interactive Mode Features
- Automation-friendly for scripts and CI pipelines
- Direct prompt execution with text output
- Model selection support
- Git integration for change reviews
## Screenshots
### Cursor CLI with Coder Tasks Integration
*Screenshot showing the cursor-cli module working with automated Coder Tasks will be added here*
- Interactive web interface for cursor-agent
- Automated code review tasks running in background
- Terminal output showing force mode execution
- MCP integration with custom tools
## Configuration
The module supports comprehensive configuration options:
### Core Features
- **MCP (Model Context Protocol)**: Automatically detects `mcp.json` configuration or uses custom path
- **Rules System**: Supports `.cursor/rules` directory for custom agent behavior
- **Force Mode**: Enable non-interactive automation for CI/CD pipelines
- **Model Selection**: Set default AI model (gpt-5, claude-4-sonnet, etc.)
- **Environment Variables**: Respects Cursor CLI environment settings
### Available Variables
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `enable_mcp` | bool | `true` | Enable MCP (Model Context Protocol) support |
| `mcp_config_path` | string | `""` | Path to custom MCP configuration file |
| `enable_force_mode` | bool | `false` | Enable force mode for non-interactive automation |
| `default_model` | string | `""` | Default AI model (e.g., gpt-5, claude-4-sonnet) |
| `enable_rules` | bool | `true` | Enable the rules system (.cursor/rules directory) |
| `install_cursor_cli` | bool | `true` | Whether to install Cursor CLI |
| `install_agentapi` | bool | `true` | Whether to install AgentAPI web interface |
| `folder` | string | `"/home/coder"` | Working directory for cursor-agent |
## Troubleshooting
The module creates log files in the workspace's `~/.cursor-cli-module` directory. Check these files if you encounter issues:
```bash
# Check installation logs
cat ~/.cursor-cli-module/install.log
# Check runtime logs
cat ~/.cursor-cli-module/runtime.log
# Verify Cursor CLI installation
cursor-agent --help
```
### Common Issues
1. **Cursor CLI not found**: Ensure `install_cursor_cli = true` or install manually:
```bash
curl https://cursor.com/install -fsS | bash
```
2. **Permission issues**: Check that the installation script has proper permissions
3. **Path issues**: The module automatically adds Cursor CLI to PATH, but you may need to restart your shell
@@ -0,0 +1,80 @@
import { describe, expect, it } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
describe("cursor-cli", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
});
it("default output with CLI enabled", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
// Check that AgentAPI module is created
const agentapi_module = state.resources.find(
(res) => res.type === "module" && res.name === "agentapi",
);
expect(agentapi_module).not.toBeNull();
});
it("adds custom folder", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
folder: "/foo/bar",
});
// Check that AgentAPI module is created with custom folder
const agentapi_module = state.resources.find(
(res) => res.type === "module" && res.name === "agentapi",
);
expect(agentapi_module).not.toBeNull();
});
it("expect order to be set", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
order: "22",
});
// Check that AgentAPI module is created
const agentapi_module = state.resources.find(
(res) => res.type === "module" && res.name === "agentapi",
);
expect(agentapi_module).not.toBeNull();
});
it("disables CLI installation", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install_cursor_cli: "false",
install_agentapi: "false",
});
// AgentAPI module should still exist but with install_agentapi = false
const agentapi_module = state.resources.find(
(res) => res.type === "module" && res.name === "agentapi",
);
expect(agentapi_module).not.toBeNull();
});
it("enables only CLI without web interface", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install_cursor_cli: "true",
install_agentapi: "false",
});
// AgentAPI module should exist but with install_agentapi = false
const agentapi_module = state.resources.find(
(res) => res.type === "module" && res.name === "agentapi",
);
expect(agentapi_module).not.toBeNull();
});
});
+154
View File
@@ -0,0 +1,154 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.7"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "icon" {
type = string
description = "The icon to use for the app."
default = "/icon/cursor.svg"
}
variable "folder" {
type = string
description = "The folder to run Cursor CLI in."
default = "/home/coder"
}
variable "install_cursor_cli" {
type = bool
description = "Whether to install Cursor CLI."
default = true
}
variable "install_agentapi" {
type = bool
description = "Whether to install AgentAPI."
default = true
}
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.3.3"
}
variable "subdomain" {
type = bool
description = "Whether to use a subdomain for AgentAPI."
default = true
}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing Cursor CLI."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing Cursor CLI."
default = null
}
variable "enable_mcp" {
type = bool
description = "Whether to enable MCP (Model Context Protocol) support."
default = true
}
variable "mcp_config_path" {
type = string
description = "Path to the MCP configuration file (mcp.json)."
default = ""
}
variable "enable_force_mode" {
type = bool
description = "Whether to enable force mode for non-interactive automation."
default = false
}
variable "default_model" {
type = string
description = "Default AI model to use (e.g., gpt-5, claude-4-sonnet)."
default = ""
}
variable "enable_rules" {
type = bool
description = "Whether to enable the rules system (.cursor/rules directory)."
default = true
}
locals {
app_slug = "cursor-cli"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".cursor-cli-module"
}
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.1.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = "Cursor CLI"
cli_app_slug = "${local.app_slug}-terminal"
cli_app_display_name = "Cursor CLI Terminal"
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
agentapi_subdomain = var.subdomain
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = local.start_script
install_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
chmod +x /tmp/install.sh
ARG_FOLDER='${var.folder}' \
ARG_INSTALL='${var.install_cursor_cli}' \
ARG_ENABLE_MCP='${var.enable_mcp}' \
ARG_MCP_CONFIG_PATH='${var.mcp_config_path}' \
ARG_ENABLE_FORCE_MODE='${var.enable_force_mode}' \
ARG_DEFAULT_MODEL='${var.default_model}' \
ARG_ENABLE_RULES='${var.enable_rules}' \
/tmp/install.sh
EOT
}
@@ -0,0 +1,103 @@
#!/bin/bash
# Function to check if a command exists
command_exists() {
command -v "$1" > /dev/null 2>&1
}
set -o nounset
echo "--------------------------------"
echo "folder: $ARG_FOLDER"
echo "install: $ARG_INSTALL"
echo "enable_mcp: $ARG_ENABLE_MCP"
echo "mcp_config_path: $ARG_MCP_CONFIG_PATH"
echo "enable_force_mode: $ARG_ENABLE_FORCE_MODE"
echo "default_model: $ARG_DEFAULT_MODEL"
echo "enable_rules: $ARG_ENABLE_RULES"
echo "--------------------------------"
set +o nounset
if [ "${ARG_INSTALL}" = "true" ]; then
echo "Installing Cursor CLI..."
# Install Cursor CLI using the official installer
curl https://cursor.com/install -fsS | bash
# Add cursor-agent to PATH if not already there
if ! command_exists cursor-agent; then
echo 'export PATH="$HOME/.cursor/bin:$PATH"' >> "$HOME/.bashrc"
echo 'export PATH="$HOME/.cursor/bin:$PATH"' >> "$HOME/.zshrc" 2> /dev/null || true
export PATH="$HOME/.cursor/bin:$PATH"
fi
echo "Cursor CLI installed"
# Configure MCP if enabled
if [ "${ARG_ENABLE_MCP}" = "true" ]; then
echo "Configuring MCP (Model Context Protocol)..."
# Create MCP config directory if it doesn't exist
mkdir -p "$HOME/.cursor"
# If custom MCP config path is provided, copy it
if [ -n "${ARG_MCP_CONFIG_PATH}" ] && [ -f "${ARG_MCP_CONFIG_PATH}" ]; then
cp "${ARG_MCP_CONFIG_PATH}" "$HOME/.cursor/mcp.json"
echo "MCP configuration copied from ${ARG_MCP_CONFIG_PATH}"
else
# Create a basic MCP config if none exists
if [ ! -f "$HOME/.cursor/mcp.json" ]; then
cat > "$HOME/.cursor/mcp.json" << 'EOF'
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}
EOF
echo "Basic MCP configuration created"
fi
fi
fi
# Configure rules system if enabled
if [ "${ARG_ENABLE_RULES}" = "true" ]; then
echo "Setting up Cursor rules system..."
mkdir -p "$HOME/.cursor/rules"
# Create a basic rules file if none exists
if [ ! -f "$HOME/.cursor/rules/general.md" ]; then
cat > "$HOME/.cursor/rules/general.md" << 'EOF'
# General Coding Rules
## Code Style
- Use consistent indentation (2 spaces for JS/TS, 4 for Python)
- Add meaningful comments for complex logic
- Follow language-specific naming conventions
## Best Practices
- Write tests for new functionality
- Handle errors gracefully
- Use descriptive variable and function names
EOF
echo "Basic rules configuration created"
fi
fi
else
echo "Skipping Cursor CLI installation"
fi
# Verify installation
if command_exists cursor-agent; then
CURSOR_CMD=cursor-agent
elif [ -f "$HOME/.cursor/bin/cursor-agent" ]; then
CURSOR_CMD="$HOME/.cursor/bin/cursor-agent"
else
echo "Warning: Cursor CLI is not installed or not found in PATH. Please enable install_cursor_cli or install it manually."
echo "You can install it manually with: curl https://cursor.com/install -fsS | bash"
fi
echo "Cursor CLI setup complete"
@@ -0,0 +1,67 @@
#!/bin/bash
# Function to check if a command exists
command_exists() {
command -v "$1" > /dev/null 2>&1
}
# Set working directory
if [ -n "${ARG_FOLDER:-}" ] && [ -d "${ARG_FOLDER}" ]; then
cd "${ARG_FOLDER}" || {
echo "Warning: Could not change to directory ${ARG_FOLDER}, using current directory"
}
fi
# Find cursor-agent command
if command_exists cursor-agent; then
CURSOR_CMD=cursor-agent
elif [ -f "$HOME/.cursor/bin/cursor-agent" ]; then
CURSOR_CMD="$HOME/.cursor/bin/cursor-agent"
else
echo "Error: Cursor CLI is not installed. Please enable install_cursor_cli or install it manually."
echo "You can install it manually with: curl https://cursor.com/install -fsS | bash"
exit 1
fi
echo "Starting Cursor CLI in $(pwd)"
echo "Interactive mode with text output enabled"
echo "Available commands:"
echo " - Start interactive session: cursor-agent"
echo " - Non-interactive mode: cursor-agent -p 'your prompt here'"
echo " - With specific model: cursor-agent -p 'prompt' --model 'gpt-5'"
echo " - Text output format: cursor-agent -p 'prompt' --output-format text"
echo " - Force mode (non-interactive): cursor-agent -p 'prompt' --force"
echo " - List sessions: cursor-agent ls"
echo " - Resume session: cursor-agent resume"
echo ""
# Set up environment variables for configuration
if [ -n "${ARG_DEFAULT_MODEL:-}" ]; then
export CURSOR_DEFAULT_MODEL="${ARG_DEFAULT_MODEL}"
echo "Default model set to: ${ARG_DEFAULT_MODEL}"
fi
if [ "${ARG_ENABLE_FORCE_MODE:-false}" = "true" ]; then
export CURSOR_FORCE_MODE="true"
echo "Force mode enabled for non-interactive automation"
fi
if [ "${ARG_ENABLE_MCP:-true}" = "true" ]; then
echo "MCP (Model Context Protocol) support enabled"
fi
if [ "${ARG_ENABLE_RULES:-true}" = "true" ]; then
echo "Rules system enabled (.cursor/rules directory)"
fi
echo ""
# Configure for interactive mode with text output
# If no arguments provided, start in interactive mode
if [ $# -eq 0 ]; then
echo "Starting interactive session..."
exec "$CURSOR_CMD"
else
# Pass through all arguments for custom usage
exec "$CURSOR_CMD" "$@"
fi
+2 -33
View File
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.3.1"
version = "1.2.1"
agent_id = coder_agent.example.id
}
```
@@ -29,39 +29,8 @@ module "cursor" {
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.3.1"
version = "1.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
```
### Configure MCP servers for Cursor
Provide a JSON-encoded string via the `mcp` input. When set, the module writes the value to `~/.cursor/mcp.json` using a `coder_script` on workspace start.
The following example configures Cursor to use the GitHub MCP server with authentication facilitated by the [`coder_external_auth`](https://coder.com/docs/admin/external-auth#configure-a-github-oauth-app) resource.
```tf
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.3.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
mcp = jsonencode({
mcpServers = {
"github" : {
"url" : "https://api.githubcopilot.com/mcp/",
"headers" : {
"Authorization" : "Bearer ${data.coder_external_auth.github.access_token}",
},
"type" : "http"
}
}
})
}
data "coder_external_auth" "github" {
id = "github"
}
```
+1 -28
View File
@@ -1,13 +1,8 @@
import { describe, it, expect } from "bun:test";
import { describe, expect, it } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
runContainer,
execContainer,
removeContainer,
findResourceInstance,
readFileContainer,
} from "~test";
describe("cursor", async () => {
@@ -90,26 +85,4 @@ describe("cursor", async () => {
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBe(22);
});
it("writes ~/.cursor/mcp.json when mcp provided", async () => {
const id = await runContainer("alpine");
try {
const mcp = JSON.stringify({ servers: { demo: { url: "http://localhost:1234" } } });
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
mcp,
});
const script = findResourceInstance(state, "coder_script", "cursor_mcp").script;
const resp = await execContainer(id, ["sh", "-c", script]);
if (resp.exitCode !== 0) {
console.log(resp.stdout);
console.log(resp.stderr);
}
expect(resp.exitCode).toBe(0);
const content = await readFileContainer(id, "/root/.cursor/mcp.json");
expect(content).toBe(mcp);
} finally {
await removeContainer(id);
}
});
});
-26
View File
@@ -50,20 +50,9 @@ variable "display_name" {
default = "Cursor Desktop"
}
variable "mcp" {
type = string
description = "JSON-encoded string to configure MCP servers for Cursor. When set, writes ~/.cursor/mcp.json."
default = ""
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
locals {
mcp_b64 = var.mcp != "" ? base64encode(var.mcp) : ""
}
resource "coder_app" "cursor" {
agent_id = var.agent_id
external = true
@@ -86,21 +75,6 @@ resource "coder_app" "cursor" {
])
}
resource "coder_script" "cursor_mcp" {
count = var.mcp != "" ? 1 : 0
agent_id = var.agent_id
display_name = "Cursor MCP"
icon = "/icon/cursor.svg"
run_on_start = true
start_blocks_login = false
script = <<-EOT
#!/bin/sh
set -eu
mkdir -p "$HOME/.cursor"
echo -n "${local.mcp_b64}" | base64 -d > "$HOME/.cursor/mcp.json"
EOT
}
output "cursor_url" {
value = coder_app.cursor.url
description = "Cursor IDE Desktop URL."
+2 -2
View File
@@ -13,7 +13,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener
```tf
module "goose" {
source = "registry.coder.com/coder/goose/coder"
version = "2.1.1"
version = "2.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
@@ -79,7 +79,7 @@ resource "coder_agent" "main" {
module "goose" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/goose/coder"
version = "2.1.1"
version = "2.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
+1 -1
View File
@@ -139,7 +139,7 @@ EOT
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.1.1"
version = "1.1.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
+6 -6
View File
@@ -14,7 +14,7 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.3"
version = "1.0.2"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -39,7 +39,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.3"
version = "1.0.2"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
@@ -52,7 +52,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.3"
version = "1.0.2"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
# Show parameter with limited options
@@ -66,7 +66,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.3"
version = "1.0.2"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -81,7 +81,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.3"
version = "1.0.2"
agent_id = coder_agent.example.id
folder = "/workspace/project"
@@ -107,7 +107,7 @@ module "jetbrains" {
module "jetbrains_pycharm" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.3"
version = "1.0.2"
agent_id = coder_agent.example.id
folder = "/workspace/project"
@@ -1,131 +0,0 @@
run "requires_agent_and_folder" {
command = plan
# Setting both required vars should plan
variables {
agent_id = "foo"
folder = "/home/coder"
}
}
run "creates_parameter_when_default_empty_latest" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
major_version = "latest"
}
# When default is empty, a coder_parameter should be created
assert {
condition = can(data.coder_parameter.jetbrains_ides[0].type)
error_message = "Expected data.coder_parameter.jetbrains_ides to exist when default is empty"
}
}
run "no_apps_when_default_empty" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
}
assert {
condition = length(resource.coder_app.jetbrains) == 0
error_message = "Expected no coder_app resources when default is empty"
}
}
run "single_app_when_default_GO" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
}
assert {
condition = length(resource.coder_app.jetbrains) == 1
error_message = "Expected exactly one coder_app when default contains GO"
}
}
run "url_contains_required_params" {
command = apply
variables {
agent_id = "test-agent-123"
folder = "/custom/project/path"
default = ["GO"]
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("jetbrains://gateway/coder", app.url)) > 0])
error_message = "URL must contain jetbrains scheme"
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&folder=/custom/project/path", app.url)) > 0])
error_message = "URL must include folder path"
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_product_code=GO", app.url)) > 0])
error_message = "URL must include product code"
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_build_number=", app.url)) > 0])
error_message = "URL must include build number"
}
}
run "includes_agent_name_when_set" {
command = apply
variables {
agent_id = "test-agent-123"
agent_name = "main-agent"
folder = "/custom/project/path"
default = ["GO"]
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&agent_name=main-agent", app.url)) > 0])
error_message = "URL must include agent_name when provided"
}
}
run "parameter_order_when_default_empty" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
coder_parameter_order = 5
}
assert {
condition = data.coder_parameter.jetbrains_ides[0].order == 5
error_message = "Expected coder_parameter order to be set to 5"
}
}
run "app_order_when_default_not_empty" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
coder_app_order = 10
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.order == 10])
error_message = "Expected coder_app order to be set to 10"
}
}
-1
View File
@@ -231,7 +231,6 @@ resource "coder_app" "jetbrains" {
icon = local.options_metadata[each.key].icon
external = true
order = var.coder_app_order
group = var.group
url = join("", [
"jetbrains://gateway/coder?&workspace=", # requires 2.6.3+ version of Toolbox
data.coder_workspace.me.name,
@@ -1,33 +0,0 @@
---
display_name: VSCode Desktop Core
description: Building block for modules that need to link to an external VSCode-based IDE
icon: ../../../../.icons/coder.svg
verified: true
tags: [internal, library]
---
# VS Code Desktop Core
> [!CAUTION]
> We do not recommend using this module directly. Instead, please consider using one of our [Desktop IDE modules](https://registry.coder.com/modules?search=tag%3Aide).
The VSCode Desktop Core module is a building block for modules that need to expose access to VSCode-based IDEs. It is intended primarily to be used as a library to create modules for VSCode-based IDEs.
```tf
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
agent_id = var.agent_id
coder_app_icon = "/icon/code.svg"
coder_app_slug = "vscode"
coder_app_display_name = "VS Code Desktop"
coder_app_order = var.order
coder_app_group = var.group
folder = var.folder
open_recent = var.open_recent
protocol = "vscode"
}
```
@@ -1,100 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
// hardcoded coder_app name in main.tf
const appName = "vscode-desktop";
const defaultVariables = {
agent_id: "foo",
coder_app_icon: "/icon/code.svg",
coder_app_slug: "vscode",
coder_app_display_name: "VS Code Desktop",
protocol: "vscode",
}
describe("vscode-desktop-core", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, defaultVariables);
it("default output", async () => {
const state = await runTerraformApply(import.meta.dir, defaultVariables);
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === appName,
);
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBeNull();
});
it("adds folder", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
...defaultVariables
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("adds folder and open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
open_recent: "true",
...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("adds folder but not open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
openRecent: "false",
...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("adds open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
open_recent: "true",
...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("expect order to be set", async () => {
const state = await runTerraformApply(import.meta.dir, {
coder_app_order: "22",
...defaultVariables
});
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === appName,
);
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBe(22);
});
});
@@ -1,92 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "folder" {
type = string
description = "The folder to open in the IDE."
default = ""
}
variable "open_recent" {
type = bool
description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open."
default = false
}
variable "protocol" {
type = string
description = "The URI protocol for the IDE."
}
variable "coder_app_icon" {
type = string
description = "The icon of the coder_app."
}
variable "coder_app_slug" {
type = string
description = "The slug of the coder_app."
}
variable "coder_app_display_name" {
type = string
description = "The display name of the coder_app."
}
variable "coder_app_order" {
type = number
description = "The order of the coder_app."
default = null
}
variable "coder_app_group" {
type = string
description = "The group of the coder_app."
default = null
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_app" "vscode-desktop" {
agent_id = var.agent_id
external = true
icon = var.coder_app_icon
slug = var.coder_app_slug
display_name = var.coder_app_display_name
order = var.coder_app_order
group = var.coder_app_group
# While the call to "join" is not strictly necessary, it makes the URL more readable.
url = join("", [
"${var.protocol}://coder.coder-remote/open",
"?owner=${data.coder_workspace_owner.me.name}",
"&workspace=${data.coder_workspace.me.name}",
var.folder != "" ? join("", ["&folder=", var.folder]) : "",
var.open_recent ? "&openRecent" : "",
"&url=${data.coder_workspace.me.access_url}",
# NOTE: There is a protocol whitelist for the token replacement, so this will only work with the protocols hardcoded in the front-end.
# (https://github.com/coder/coder/blob/6ba4b5bbc95e2e528d7f5b1e31fffa200ae1a6db/site/src/modules/apps/apps.ts#L18)
"&token=$SESSION_TOKEN",
])
}
output "ide_uri" {
value = coder_app.vscode-desktop.url
description = "IDE URI."
}
+4 -32
View File
@@ -19,7 +19,7 @@ Zed is a high-performance, multiplayer code editor from the creators of Atom and
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
version = "1.0.1"
agent_id = coder_agent.example.id
}
```
@@ -32,7 +32,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
version = "1.0.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -44,7 +44,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
version = "1.0.1"
agent_id = coder_agent.example.id
display_name = "Zed Editor"
order = 1
@@ -57,36 +57,8 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
version = "1.0.1"
agent_id = coder_agent.example.id
agent_name = coder_agent.example.name
}
```
### Configure Zed settings including MCP servers
Zed stores settings at `~/.config/zed/settings.json` by default. If `XDG_CONFIG_HOME` is set on Linux, settings will be at `$XDG_CONFIG_HOME/zed/settings.json`.
You can declaratively set/merge settings with the `settings` input. Provide a JSON string (e.g., via `jsonencode(...)`). For example, to configure MCP servers:
```tf
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
settings = jsonencode({
context_servers = {
your-mcp-server = {
source = "custom"
command = "some-command"
args = ["arg-1", "arg-2"]
env = {}
}
}
})
}
```
See Zeds settings files documentation: https://zed.dev/docs/configuring-zed#settings-files
-31
View File
@@ -50,14 +50,7 @@ variable "display_name" {
default = "Zed"
}
variable "settings" {
type = string
description = "JSON encoded settings.json"
default = ""
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
locals {
@@ -67,30 +60,6 @@ locals {
hostname = var.agent_name != "" ? "${local.agent_name}.${local.workspace_name}.${local.owner_name}.coder" : "${local.workspace_name}.coder"
}
resource "coder_script" "zed_settings" {
agent_id = var.agent_id
display_name = "Configure Zed settings"
icon = "/icon/zed.svg"
run_on_start = true
script = <<-EOT
set -eu
SETTINGS_JSON='${replace(var.settings, "\"", "\\\"")}'
if [ -z "$${SETTINGS_JSON}" ] || [ "$${SETTINGS_JSON}" = "{}" ]; then
exit 0
fi
CONFIG_HOME="$${XDG_CONFIG_HOME:-$HOME/.config}"
ZED_DIR="$${CONFIG_HOME}/zed"
mkdir -p "$${ZED_DIR}"
SETTINGS_FILE="$${ZED_DIR}/settings.json"
if command -v jq >/dev/null 2>&1 && [ -s "$${SETTINGS_FILE}" ]; then
tmpfile="$(mktemp)"
jq -s '.[0] * .[1]' "$${SETTINGS_FILE}" <(printf '%s\n' "$${SETTINGS_JSON}") > "$${tmpfile}" && mv "$${tmpfile}" "$${SETTINGS_FILE}"
else
printf '%s\n' "$${SETTINGS_JSON}" > "$${SETTINGS_FILE}"
fi
EOT
}
resource "coder_app" "zed" {
agent_id = var.agent_id
display_name = var.display_name
-40
View File
@@ -1,40 +0,0 @@
run "default_output" {
command = apply
variables {
agent_id = "foo"
}
assert {
condition = output.zed_url == "zed://ssh/default.coder"
error_message = "zed_url did not match expected default URL"
}
}
run "adds_folder" {
command = apply
variables {
agent_id = "foo"
folder = "/foo/bar"
}
assert {
condition = output.zed_url == "zed://ssh/default.coder/foo/bar"
error_message = "zed_url did not include provided folder path"
}
}
run "adds_agent_name" {
command = apply
variables {
agent_id = "foo"
agent_name = "myagent"
}
assert {
condition = output.zed_url == "zed://ssh/myagent.default.default.coder"
error_message = "zed_url did not include agent_name in hostname"
}
}
@@ -14,26 +14,11 @@ Provision Devcontainers as [Coder workspaces](https://coder.com/docs/workspaces)
### Infrastructure
#### Running Coder inside Docker
Coder must have access to a running Docker socket, and the `coder` user must be a member of the `docker` group:
If you installed Coder as a container within Docker, you will have to do the following things:
- Make the the Docker socket available to the container
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
- You can get the GID by running `getent group docker` on the **host** machine
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
#### Running Coder outside of Docker
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
```sh
```shell
# Add coder user to Docker group
sudo adduser coder docker
sudo usermod -aG docker coder
# Restart Coder server
sudo systemctl restart coder
+1 -16
View File
@@ -16,22 +16,7 @@ Provision Docker containers as [Coder workspaces](https://coder.com/docs/workspa
### Infrastructure
#### Running Coder inside Docker
If you installed Coder as a container within Docker, you will have to do the following things:
- Make the the Docker socket available to the container
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
- You can get the GID by running `getent group docker` on the **host** machine
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
#### Running Coder outside of Docker
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
```sh
# Add coder user to Docker group
@@ -8,8 +8,6 @@ tags: [vm, linux, gcp, devcontainer]
# Remote Development in a Devcontainer on Google Compute Engine
Provision a Devcontainer on Google Compute Engine instances as Coder workspaces
![Architecture Diagram](../../.images/gcp-devcontainer-architecture.svg)
## Prerequisites
@@ -8,8 +8,6 @@ tags: [vm, linux, gcp]
# Remote Development on Google Compute Engine (Linux)
Provision Google Compute Engine instances as Coder workspaces
## Prerequisites
### Authentication
@@ -8,8 +8,6 @@ tags: [vm-container, linux, gcp]
# Remote Development on Google Compute Engine (VM Container)
Provision Google Compute Engine instances as Coder workspaces.
## Prerequisites
### Authentication
@@ -8,8 +8,6 @@ tags: [vm, windows, gcp]
# Remote Development on Google Compute Engine (Windows)
Provision Google Compute Engine instances as Coder workspaces
## Prerequisites
### Authentication
@@ -8,8 +8,6 @@ tags: [kubernetes, containers, docker-in-docker]
# envbox
Provision envbox pods as Coder workspaces
## Introduction
`envbox` is an image that enables creating non-privileged containers capable of running system-level software (e.g. `dockerd`, `systemd`, etc) in Kubernetes.
+1 -1
View File
@@ -1,7 +1,7 @@
---
display_name: "Eric Paulsen"
bio: "Field CTO, EMEA @ Coder"
avatar: "./.images/avatar.png"
avatar_url: "./.images/avatar.png"
github: "ericpaulsen"
linkedin: "https://www.linkedin.com/in/ericpaulsen17" # Optional
website: "https://ericpaulsen.io" # Optional
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

-32
View File
@@ -1,32 +0,0 @@
---
display_name: "Mark Milligan"
bio: "VP of Revenue at https://nuon.co. Former VP of Sales at Coder. Love building startup revenue teams and tinkering with technology."
avatar: "./.images/avatar.png"
github: "sharkymark"
linkedin: "https://www.linkedin.com/in/marktmilligan" # Optional
website: "https://markmilligan.io" # Optional
support_email: "mtm20176@gmail.com" # Optional
status: "community"
---
# Mark Milligan
Former VP of Sales at Coder for 4 years, and now VP of Revenue at Nuon. I love building startup revenue teams and tinkering with technology.
## About Me
Visit my [website](https://markmilligan.io) to learn more about my work and interests.
## Links
[My presentation about Great White Sharks](https://docs.google.com/presentation/d/13I3Af7l-ZSVCh-ovEvOKIM30ABIvNKhkRC3CnYZN450/edit?slide=id.p#slide=id.p) - given twice in 2020 and 2021 to the Coder team.
[NOAA Radar](https://radar.weather.gov/)
[Flight Radar](https://www.flightradar24.com/airport/aus)
### Webcams
[Austin - facing south](https://cctv.austinmobility.io/image/51.jpg)
[Austin - facing north](https://cctv.austinmobility.io/image/52.jpg)
@@ -1,36 +0,0 @@
---
display_name: "Claude Code AI Agent Template"
description: An experimental AI agent integration with Claude CodeAI agent
icon: "../../../../.icons/claude.svg"
verified: false
tags: ["ai", "docker", "container", "claude", "agent", "tasks"]
---
# AI agent template for a workspace in a container on a Docker host
An experimental AI agent integration with Claude CodeAI agent
## Docker image
1. Based on Coder-managed image `codercom/example-universal:ubuntu`
[Image on DockerHub](https://hub.docker.com/r/codercom/example-universal)
## Apps included
1. A web-based terminal
1. code-server Web IDE
1. A [sample app](https://github.com/gothinkster/realworld) to test the environment
1. [Claude Code AI agent](https://www.anthropic.com/claude-code) to assist with development tasks
## Resources
[Coder docs on AI agents and tasks](https://coder.com/docs/ai-coder/tasks)
[main.tf for Coder example](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/tasks-docker/main.tf)
[Claude Code Coder Terraform module](https://registry.coder.com/modules/coder/claude-code)
[Docker Terraform provider](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs)
[Coder Terraform provider](https://registry.terraform.io/providers/coder/coder/latest/docs)
@@ -1,363 +0,0 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
}
}
provider "docker" {
host = var.socket
}
provider "coder" {
}
data "coder_workspace" "me" {
}
data "coder_workspace_owner" "me" {
}
data "coder_provisioner" "me" {
}
variable "socket" {
type = string
description = <<-EOF
The Unix socket that the Docker daemon listens on and how containers
communicate with the Docker daemon.
Either Unix or TCP
e.g., unix:///var/run/docker.sock
EOF
default = "unix:///var/run/docker.sock"
}
variable "anthropic_api_key" {
type = string
description = "Generate one at: https://console.anthropic.com/settings/keys"
sensitive = true
}
resource "coder_env" "anthropic_api_key" {
agent_id = coder_agent.dev.id
name = "CODER_MCP_CLAUDE_API_KEY"
value = var.anthropic_api_key
}
# The Claude Code module does the automatic task reporting
# Other agent modules: https://registry.coder.com/modules?search=agent
# Or use a custom agent:
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
version = "2.0.0"
agent_id = coder_agent.dev.id
folder = "/home/coder/projects"
install_claude_code = true
claude_code_version = "latest"
order = 999
experiment_post_install_script = data.coder_parameter.setup_script.value
# This enables Coder Tasks
experiment_report_tasks = true
}
# We are using presets to set the prompts, image, and set up instructions
# See https://coder.com/docs/admin/templates/extending-templates/parameters#workspace-presets
data "coder_workspace_preset" "default" {
name = "Real World App: Angular + Django"
default = true
parameters = {
"system_prompt" = <<-EOT
-- Framing --
You are a helpful assistant that can help with code. You are running inside a Coder Workspace and provide status updates to the user via Coder MCP. Stay on track, feel free to debug, but when the original plan fails, do not choose a different route/architecture without checking the user first.
-- Tool Selection --
- playwright: previewing your changes after you made them
to confirm it worked as expected
- desktop-commander - use only for commands that keep running
(servers, dev watchers, GUI apps).
- Built-in tools - use for everything else:
(file operations, git commands, builds & installs, one-off shell commands)
Remember this decision rule:
- Stays running? desktop-commander
- Finishes immediately? built-in tools
-- Context --
There is an existing app and tmux dev server running on port 8000. Be sure to read it's CLAUDE.md (./realworld-django-rest-framework-angular/CLAUDE.md) to learn more about it.
Since this app is for demo purposes and the user is previewing the homepage and subsequent pages, aim to make the first visual change/prototype very quickly so the user can preview it, then focus on backend or logic which can be a more involved, long-running architecture plan.
EOT
"setup_script" = <<-EOT
# Set up projects dir
mkdir -p /home/coder/projects
cd $HOME/projects
# Packages: Install additional packages
sudo apt-get update && sudo apt-get install -y tmux
if ! command -v google-chrome >/dev/null 2>&1; then
yes | npx playwright install chrome
fi
# MCP: Install and configure MCP Servers
npm install -g @wonderwhy-er/desktop-commander
claude mcp add playwright npx -- @playwright/mcp@latest --headless --isolated --no-sandbox
claude mcp add desktop-commander desktop-commander
# Repo: Clone and pull changes from the git repository
if [ ! -d "realworld-django-rest-framework-angular" ]; then
git clone https://github.com/coder-contrib/realworld-django-rest-framework-angular.git
else
cd realworld-django-rest-framework-angular
git fetch
# Check for uncommitted changes
if git diff-index --quiet HEAD -- && \
[ -z "$(git status --porcelain --untracked-files=no)" ] && \
[ -z "$(git log --branches --not --remotes)" ]; then
echo "Repo is clean. Pulling latest changes..."
git pull
else
echo "Repo has uncommitted or unpushed changes. Skipping pull."
fi
cd ..
fi
# Initialize: Start the development server
cd realworld-django-rest-framework-angular && ./start-dev.sh
EOT
"preview_port" = "4200"
"container_image" = "codercom/example-universal:ubuntu"
}
}
# Advanced parameters (these are all set via preset)
data "coder_parameter" "system_prompt" {
name = "system_prompt"
display_name = "System Prompt"
type = "string"
form_type = "textarea"
description = "System prompt for the agent with generalized instructions"
mutable = false
}
data "coder_parameter" "ai_prompt" {
type = "string"
name = "AI Prompt"
default = ""
description = "Write a prompt for Claude Code"
mutable = true
}
data "coder_parameter" "setup_script" {
name = "setup_script"
display_name = "Setup Script"
type = "string"
form_type = "textarea"
description = "Script to run before running the agent"
mutable = false
}
data "coder_parameter" "container_image" {
name = "container_image"
display_name = "Container Image"
type = "string"
default = "codercom/example-universal:ubuntu"
mutable = false
}
data "coder_parameter" "preview_port" {
name = "preview_port"
display_name = "Preview Port"
description = "The port the web app is running to preview in Tasks"
type = "number"
default = "3000"
mutable = false
}
# Other variables for Claude Code
resource "coder_env" "claude_task_prompt" {
agent_id = coder_agent.dev.id
name = "CODER_MCP_CLAUDE_TASK_PROMPT"
value = data.coder_parameter.ai_prompt.value
}
resource "coder_env" "app_status_slug" {
agent_id = coder_agent.dev.id
name = "CODER_MCP_APP_STATUS_SLUG"
value = "claude-code"
}
resource "coder_env" "claude_system_prompt" {
agent_id = coder_agent.dev.id
name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT"
value = data.coder_parameter.system_prompt.value
}
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/coder-login/coder"
agent_id = coder_agent.dev.id
}
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
agent_id = coder_agent.dev.id
}
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
agent_id = coder_agent.dev.id
folder = "/home/coder/projects"
}
module "git-config" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-config/coder"
agent_id = coder_agent.dev.id
}
resource "coder_agent" "dev" {
arch = data.coder_provisioner.me.arch
os = "linux"
# The following metadata blocks are optional. They are used to display
# information about your workspace in the dashboard. You can remove them
# if you don't want to display any information.
# For basic resources, you can use the `coder stat` command.
# If you need more control, you can write your own script.
metadata {
display_name = "CPU Usage"
key = "0_cpu_usage"
script = "coder stat cpu"
interval = 10
timeout = 1
}
metadata {
display_name = "RAM Usage"
key = "1_ram_usage"
script = "coder stat mem"
interval = 10
timeout = 1
}
metadata {
display_name = "Home Disk"
key = "3_home_disk"
script = "coder stat disk --path $${HOME}"
interval = 60
timeout = 1
}
metadata {
display_name = "CPU Usage (Host)"
key = "4_cpu_usage_host"
script = "coder stat cpu --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Memory Usage (Host)"
key = "5_mem_usage_host"
script = "coder stat mem --host"
interval = 10
timeout = 1
}
display_apps {
vscode = true
vscode_insiders = false
ssh_helper = false
port_forwarding_helper = true
web_terminal = true
}
startup_script_behavior = "non-blocking"
connection_timeout = 300
env = {
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
}
startup_script = <<EOT
#!/bin/sh
EOT
}
resource "coder_app" "preview" {
agent_id = coder_agent.dev.id
slug = "preview"
display_name = "Preview your app"
icon = "${data.coder_workspace.me.access_url}/emojis/1f50e.png"
url = "http://localhost:${data.coder_parameter.preview_port.value}"
share = "authenticated"
subdomain = true
open_in = "tab"
order = 0
healthcheck {
url = "http://localhost:${data.coder_parameter.preview_port.value}/"
interval = 5
threshold = 15
}
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = data.coder_parameter.container_image.value
# Uses lower() to avoid Docker restriction on container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
hostname = lower(data.coder_workspace.me.name)
dns = ["1.1.1.1"]
# Use the docker gateway if the access URL is 127.0.0.1
#entrypoint = ["sh", "-c", replace(coder_agent.dev.init_script, "127.0.0.1", "host.docker.internal")]
# Use the docker gateway if the access URL is 127.0.0.1
command = [
"sh", "-c",
<<EOT
trap '[ $? -ne 0 ] && echo === Agent script exited with non-zero code. Sleeping infinitely to preserve logs... && sleep infinity' EXIT
${replace(coder_agent.dev.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")}
EOT
]
env = ["CODER_AGENT_TOKEN=${coder_agent.dev.token}"]
volumes {
container_path = "/home/coder/"
volume_name = docker_volume.coder_volume.name
read_only = false
}
host {
host = "host.docker.internal"
ip = "host-gateway"
}
}
resource "docker_volume" "coder_volume" {
name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}"
}
resource "coder_metadata" "workspace_info" {
count = data.coder_workspace.me.start_count
resource_id = docker_container.workspace[0].id
item {
key = "image"
value = data.coder_parameter.container_image.value
}
}
+97 -486
View File
@@ -2,221 +2,32 @@
# Tag Release Script
# Automatically detects modules that need tagging and creates release tags
# Usage: ./tag_release.sh [OPTIONS]
# Usage: ./tag_release.sh
# Operates on the current checked-out commit
set -euo pipefail
MODULES_TO_TAG=()
AUTO_APPROVE=false
DRY_RUN=false
VERBOSE=false
QUIET=false
OUTPUT_FORMAT="plain"
TARGET_NAMESPACE=""
TARGET_MODULE=""
SKIP_PUSH=false
JSON_OUTPUT='{
"metadata": {},
"summary": {},
"modules": [],
"warnings": [],
"errors": []
}'
readonly EXIT_SUCCESS=0
readonly EXIT_ERROR=1
readonly EXIT_NO_ACTION_NEEDED=2
readonly EXIT_VALIDATION_FAILED=3
usage() {
cat << EOF
Usage: $0 [OPTIONS]
OPTIONS:
-y, --auto-approve Skip confirmation prompt
-d, --dry-run Preview without creating tags
-v, --verbose Detailed output
-q, --quiet Minimal output
-f, --format=FORMAT Output format: 'plain' or 'json'
-n, --namespace=NAME Target specific namespace
-m, --module=NAME Target specific module
-s, --skip-push Create tags but don't push
-h, --help Show this help
EXAMPLES:
$0 # Interactive mode
$0 -y -q -f json # CI/CD automation
$0 -d -v # Test with verbose output
$0 -m code-server -d # Target specific module
$0 -n coder -m code-server -d # Target module in namespace
Exit codes: 0=success, 1=error, 2=no action needed, 3=validation failed
EOF
exit 0
}
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
case "$level" in
"ERROR")
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
add_json_error "script_error" "$message"
elif [[ "$QUIET" != "true" ]]; then
echo "$message" >&2
fi
;;
"WARN")
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
add_json_warning "" "$message" "warning"
elif [[ "$QUIET" != "true" ]]; then
echo "⚠️ $message" >&2
fi
;;
"INFO")
if [[ "$QUIET" != "true" && "$OUTPUT_FORMAT" != "json" ]]; then
echo "$message"
fi
;;
"SUCCESS")
if [[ "$QUIET" != "true" && "$OUTPUT_FORMAT" != "json" ]]; then
echo "$message"
fi
;;
"DEBUG")
if [[ "$VERBOSE" == "true" && "$OUTPUT_FORMAT" != "json" ]]; then
echo "🔍 [$timestamp] $message" >&2
fi
;;
esac
}
add_json_error() {
local type="$1"
local message="$2"
local details="${3:-}"
local exit_code="${4:-1}"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg type "$type" --arg msg "$message" --arg details "$details" --argjson code "$exit_code" \
'.errors += [{"type": $type, "message": $msg, "details": $details, "exit_code": $code}]')
}
add_json_warning() {
local module="$1"
local message="$2"
local type="$3"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg module "$module" --arg msg "$message" --arg type "$type" \
'.warnings += [{"module": $module, "message": $msg, "type": $type}]')
}
add_json_module() {
local namespace="$1"
local module_name="$2"
local path="$3"
local version="$4"
local tag_name="$5"
local status="$6"
local already_existed="$7"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg ns "$namespace" --arg name "$module_name" --arg path "$path" \
--arg version "$version" --arg tag "$tag_name" --arg status "$status" --argjson existed "$already_existed" \
'.modules += [{"namespace": $ns, "module_name": $name, "path": $path, "version": $version, "tag_name": $tag, "status": $status, "already_existed": $existed}]')
}
parse_arguments() {
while [[ $# -gt 0 ]]; do
case $1 in
-y | --auto-approve)
AUTO_APPROVE=true
shift
;;
-d | --dry-run)
DRY_RUN=true
shift
;;
-v | --verbose)
VERBOSE=true
shift
;;
-q | --quiet)
QUIET=true
shift
;;
-f | --format=* | --format)
if [[ "$1" == "-f" || "$1" == "--format" ]]; then
if [[ -z "$2" ]]; then
log "ERROR" "Option $1 requires a value"
exit $EXIT_ERROR
fi
OUTPUT_FORMAT="$2"
shift 2
else
OUTPUT_FORMAT="${1#*=}"
shift
fi
if [[ "$OUTPUT_FORMAT" != "plain" && "$OUTPUT_FORMAT" != "json" ]]; then
log "ERROR" "Invalid format '$OUTPUT_FORMAT'. Must be 'plain' or 'json'"
exit $EXIT_ERROR
fi
;;
-n | --namespace=* | --namespace)
if [[ "$1" == "-n" || "$1" == "--namespace" ]]; then
if [[ -z "$2" ]]; then
log "ERROR" "Option $1 requires a value"
exit $EXIT_ERROR
fi
TARGET_NAMESPACE="$2"
shift 2
else
TARGET_NAMESPACE="${1#*=}"
shift
fi
;;
-m | --module=* | --module)
if [[ "$1" == "-m" || "$1" == "--module" ]]; then
if [[ -z "$2" ]]; then
log "ERROR" "Option $1 requires a value"
exit $EXIT_ERROR
fi
TARGET_MODULE="$2"
shift 2
else
TARGET_MODULE="${1#*=}"
shift
fi
;;
-s | --skip-push)
SKIP_PUSH=true
shift
;;
-h | --help)
usage
;;
*)
log "ERROR" "Unknown option: $1"
echo "Use --help for usage information."
exit $EXIT_ERROR
;;
esac
done
if [[ "$VERBOSE" == "true" && "$QUIET" == "true" ]]; then
echo "❌ --verbose and --quiet cannot be used together" >&2
exit $EXIT_ERROR
fi
echo "Usage: $0"
echo ""
echo "This script will:"
echo " 1. Scan all modules in the registry"
echo " 2. Check which modules need new release tags"
echo " 3. Extract version information from README files"
echo " 4. Generate a report for confirmation"
echo " 5. Create and push release tags after confirmation"
echo ""
echo "The script operates on the current checked-out commit."
echo "Make sure you have checked out the commit you want to tag before running."
exit 1
}
validate_version() {
local version="$1"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
log "DEBUG" "Invalid version format: '$version'. Expected X.Y.Z format."
echo "Invalid version format: '$version'. Expected X.Y.Z format." >&2
return 1
fi
return 0
@@ -227,12 +38,7 @@ extract_version_from_readme() {
local namespace="$2"
local module_name="$3"
log "DEBUG" "Extracting version from $readme_path for $namespace/$module_name"
[ ! -f "$readme_path" ] && {
log "DEBUG" "README file not found: $readme_path"
return 1
}
[ ! -f "$readme_path" ] && return 1
local version_line
version_line=$(grep -E "source\s*=\s*\"registry\.coder\.com/${namespace}/${module_name}" "$readme_path" | head -1 || echo "")
@@ -241,7 +47,6 @@ extract_version_from_readme() {
local version
version=$(echo "$version_line" | sed -n 's/.*version\s*=\s*"\([^"]*\)".*/\1/p')
if [ -n "$version" ]; then
log "DEBUG" "Found version '$version' from source line: $version_line"
echo "$version"
return 0
fi
@@ -251,12 +56,10 @@ extract_version_from_readme() {
fallback_version=$(grep -E 'version\s*=\s*"[0-9]+\.[0-9]+\.[0-9]+"' "$readme_path" | head -1 | sed 's/.*version\s*=\s*"\([^"]*\)".*/\1/' || echo "")
if [ -n "$fallback_version" ]; then
log "DEBUG" "Found fallback version '$fallback_version'"
echo "$fallback_version"
return 0
fi
log "DEBUG" "No version found in $readme_path"
return 1
}
@@ -267,54 +70,29 @@ check_module_needs_tagging() {
local tag_name="release/${namespace}/${module_name}/v${readme_version}"
log "DEBUG" "Checking if tag exists: $tag_name"
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
log "DEBUG" "Tag $tag_name already exists"
return 1
else
log "DEBUG" "Tag $tag_name needs to be created"
return 0
fi
}
should_process_module() {
local namespace="$1"
local module_name="$2"
if [[ -n "$TARGET_NAMESPACE" && "$TARGET_NAMESPACE" != "$namespace" ]]; then
log "DEBUG" "Skipping $namespace/$module_name: namespace filter"
return 1
fi
if [[ -n "$TARGET_MODULE" && "$TARGET_MODULE" != "$module_name" ]]; then
log "DEBUG" "Skipping $namespace/$module_name: module filter"
return 1
fi
return 0
}
detect_modules_needing_tags() {
MODULES_TO_TAG=()
log "INFO" "🔍 Scanning all modules for missing release tags..."
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
fi
echo "🔍 Scanning all modules for missing release tags..."
echo ""
local all_modules
all_modules=$(find registry -mindepth 3 -maxdepth 3 -type d -path "*/modules/*" | sort -u || echo "")
[ -z "$all_modules" ] && {
log "ERROR" "No modules found to check"
return $EXIT_ERROR
echo "❌ No modules found to check"
return 1
}
local total_checked=0
local needs_tagging=0
local already_tagged=0
local skipped=0
while IFS= read -r module_path; do
if [ -z "$module_path" ]; then continue; fi
@@ -324,133 +102,64 @@ detect_modules_needing_tags() {
local module_name
module_name=$(echo "$module_path" | cut -d'/' -f4)
if ! should_process_module "$namespace" "$module_name"; then
skipped=$((skipped + 1))
continue
fi
total_checked=$((total_checked + 1))
local readme_path="$module_path/README.md"
local readme_version
if ! readme_version=$(extract_version_from_readme "$readme_path" "$namespace" "$module_name"); then
log "WARN" "$namespace/$module_name: No version found in README, skipping"
add_json_warning "$namespace/$module_name" "No version found in README, skipping" "missing_version"
skipped=$((skipped + 1))
echo "⚠️ $namespace/$module_name: No version found in README, skipping"
continue
fi
if ! validate_version "$readme_version"; then
log "WARN" "$namespace/$module_name: Invalid version format '$readme_version', skipping"
add_json_warning "$namespace/$module_name" "Invalid version format '$readme_version', skipping" "invalid_version"
skipped=$((skipped + 1))
echo "⚠️ $namespace/$module_name: Invalid version format '$readme_version', skipping"
continue
fi
local tag_name="release/$namespace/$module_name/v$readme_version"
if check_module_needs_tagging "$namespace" "$module_name" "$readme_version"; then
log "INFO" "📦 $namespace/$module_name: v$readme_version (needs tag)"
echo "📦 $namespace/$module_name: v$readme_version (needs tag)"
MODULES_TO_TAG+=("$module_path:$namespace:$module_name:$readme_version")
needs_tagging=$((needs_tagging + 1))
local status="needs_tagging"
if [[ "$DRY_RUN" == "true" ]]; then
status="would_be_tagged"
fi
add_json_module "$namespace" "$module_name" "$module_path" "$readme_version" "$tag_name" "$status" false
else
log "SUCCESS" "$namespace/$module_name: v$readme_version (already tagged)"
already_tagged=$((already_tagged + 1))
add_json_module "$namespace" "$module_name" "$module_path" "$readme_version" "$tag_name" "already_tagged" true
echo "$namespace/$module_name: v$readme_version (already tagged)"
fi
done <<< "$all_modules"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson total "$total_checked" --argjson needs "$needs_tagging" \
--argjson tagged "$already_tagged" --argjson skip "$skipped" \
'.summary.total_scanned = $total | .summary.needs_tagging = $needs | .summary.already_tagged = $tagged | .summary.skipped = $skip')
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
log "INFO" "📊 Summary: $needs_tagging of $total_checked modules need tagging"
echo ""
fi
echo ""
echo "📊 Summary: $needs_tagging of $total_checked modules need tagging"
echo ""
[ $needs_tagging -eq 0 ] && {
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
log "SUCCESS" "🎉 All modules are up to date! No tags needed."
fi
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "no_action_needed"')
return $EXIT_NO_ACTION_NEEDED
echo "🎉 All modules are up to date! No tags needed."
return 0
}
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo "## Tags to be created:"
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
echo "- \`release/$namespace/$module_name/v$version\`"
done
echo ""
fi
echo "## Tags to be created:"
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
echo "- \`release/$namespace/$module_name/v$version\`"
done
echo ""
return $EXIT_SUCCESS
}
pre_flight_checks() {
log "DEBUG" "Running pre-flight checks..."
if ! git rev-parse --git-dir > /dev/null 2>&1; then
log "ERROR" "Not in a git repository"
return $EXIT_ERROR
fi
if ! git remote get-url origin > /dev/null 2>&1; then
log "ERROR" "No 'origin' remote found"
return $EXIT_ERROR
fi
if [[ "$SKIP_PUSH" != "true" && "$DRY_RUN" != "true" ]]; then
log "DEBUG" "Testing remote connectivity..."
if ! git ls-remote --exit-code origin > /dev/null 2>&1; then
log "ERROR" "Cannot connect to remote repository"
return $EXIT_ERROR
fi
fi
if ! git rev-parse HEAD > /dev/null 2>&1; then
log "ERROR" "Cannot determine current commit"
return $EXIT_ERROR
fi
log "DEBUG" "Pre-flight checks passed"
return $EXIT_SUCCESS
return 0
}
create_and_push_tags() {
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
log "ERROR" "No modules to tag found"
return $EXIT_ERROR
echo "❌ No modules to tag found"
return 1
}
local current_commit
current_commit=$(git rev-parse HEAD)
if [[ "$DRY_RUN" == "true" ]]; then
log "INFO" "🏷️ [DRY RUN] Would create release tags for commit: $current_commit"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "dry_run" | .summary.tags_created = 0 | .summary.tags_pushed = 0')
return $EXIT_SUCCESS
fi
log "INFO" "🏷️ Creating release tags for commit: $current_commit"
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
fi
echo "🏷️ Creating release tags for commit: $current_commit"
echo ""
local created_tags=0
local failed_tags=0
local created_tag_names=()
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
@@ -458,56 +167,35 @@ create_and_push_tags() {
local tag_name="release/$namespace/$module_name/v$version"
local tag_message="Release $namespace/$module_name v$version"
log "DEBUG" "Creating tag: $tag_name"
log "INFO" "Creating tag: $tag_name"
echo "Creating tag: $tag_name"
if git tag -a "$tag_name" -m "$tag_message" "$current_commit" 2> /dev/null; then
log "SUCCESS" "Created: $tag_name"
if git tag -a "$tag_name" -m "$tag_message" "$current_commit"; then
echo "Created: $tag_name"
created_tags=$((created_tags + 1))
created_tag_names+=("$tag_name")
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created"')
else
log "ERROR" "Failed to create: $tag_name"
add_json_error "tag_creation_failed" "Failed to create tag: $tag_name" "git tag -a $tag_name -m '$tag_message' $current_commit"
echo "Failed to create: $tag_name"
failed_tags=$((failed_tags + 1))
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_creation_failed"')
fi
done
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
log "INFO" "📊 Tag creation summary:"
log "INFO" " Created: $created_tags"
log "INFO" " Failed: $failed_tags"
echo ""
fi
echo ""
echo "📊 Tag creation summary:"
echo " Created: $created_tags"
echo " Failed: $failed_tags"
echo ""
[ $created_tags -eq 0 ] && {
log "ERROR" "No tags were created successfully"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed" | .summary.tags_created = 0 | .summary.tags_pushed = 0')
return $EXIT_ERROR
echo "❌ No tags were created successfully"
return 1
}
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson created "$created_tags" '.summary.tags_created = $created')
if [[ "$SKIP_PUSH" == "true" ]]; then
log "INFO" "🚫 Skipping push (--skip-push specified)"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "tags_created_not_pushed" | .summary.tags_pushed = 0')
for tag_name in "${created_tag_names[@]}"; do
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created_not_pushed"')
done
return $EXIT_SUCCESS
fi
log "INFO" "🚀 Pushing tags to origin..."
echo "🚀 Pushing tags to origin..."
local tags_to_push=()
for tag_name in "${created_tag_names[@]}"; do
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
local tag_name="release/$namespace/$module_name/v$version"
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
tags_to_push+=("$tag_name")
fi
@@ -517,148 +205,71 @@ create_and_push_tags() {
local failed_pushes=0
if [ ${#tags_to_push[@]} -eq 0 ]; then
log "ERROR" "No valid tags found to push"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed" | .summary.tags_pushed = 0')
echo "❌ No valid tags found to push"
else
if git push --atomic origin "${tags_to_push[@]}" 2> /dev/null; then
log "SUCCESS" "Successfully pushed all ${#tags_to_push[@]} tags"
if git push --atomic origin "${tags_to_push[@]}"; then
echo "Successfully pushed all ${#tags_to_push[@]} tags"
pushed_tags=${#tags_to_push[@]}
for tag_name in "${tags_to_push[@]}"; do
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tagged_and_pushed"')
done
else
log "ERROR" "Failed to push tags"
add_json_error "push_failed" "Failed to push tags to remote" "git push --atomic origin ${tags_to_push[*]}"
echo "❌ Failed to push tags"
failed_pushes=${#tags_to_push[@]}
for tag_name in "${tags_to_push[@]}"; do
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created_push_failed"')
done
fi
fi
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson pushed "$pushed_tags" '.summary.tags_pushed = $pushed')
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
log "INFO" "📊 Push summary:"
log "INFO" " Pushed: $pushed_tags"
log "INFO" " Failed: $failed_pushes"
echo ""
fi
echo ""
echo "📊 Push summary:"
echo " Pushed: $pushed_tags"
echo " Failed: $failed_pushes"
echo ""
if [ $pushed_tags -gt 0 ]; then
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
log "SUCCESS" "🎉 Successfully created and pushed $pushed_tags release tags!"
echo ""
log "INFO" "📝 Next steps:"
log "INFO" " - Tags will be automatically published to registry.coder.com"
log "INFO" " - Monitor the registry website for updates"
log "INFO" " - Check GitHub releases for any issues"
fi
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "success"')
return $EXIT_SUCCESS
else
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed"')
return $EXIT_ERROR
echo "🎉 Successfully created and pushed $pushed_tags release tags!"
echo ""
echo "📝 Next steps:"
echo " - Tags will be automatically published to registry.coder.com"
echo " - Monitor the registry website for updates"
echo " - Check GitHub releases for any issues"
fi
}
finalize_json_output() {
local timestamp
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
local current_commit
current_commit=$(git rev-parse HEAD 2> /dev/null || echo "unknown")
local command_line="$0 $*"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg ts "$timestamp" --arg commit "$current_commit" \
--arg cmd "$command_line" \
'.metadata.timestamp = $ts | .metadata.commit = $commit | .metadata.command = $cmd')
echo "$JSON_OUTPUT"
return 0
}
main() {
parse_arguments "$@"
[ $# -gt 0 ] && usage
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
if ! command -v jq > /dev/null 2>&1; then
echo '{"error": "jq is required for JSON output format but not found"}' >&2
exit $EXIT_ERROR
fi
echo "🚀 Coder Registry Tag Release Script"
echo "Operating on commit: $(git rev-parse HEAD)"
echo ""
if ! git rev-parse --git-dir > /dev/null 2>&1; then
echo "❌ Not in a git repository"
exit 1
fi
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
log "INFO" "🚀 Coder Registry Tag Release Script"
log "INFO" "Operating on commit: $(git rev-parse HEAD 2> /dev/null || echo 'unknown')"
echo ""
fi
detect_modules_needing_tags || exit 1
if ! pre_flight_checks; then
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "preflight_failed"')
finalize_json_output "$@"
fi
exit $EXIT_ERROR
fi
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
echo "✨ No modules need tagging. All done!"
exit 0
}
local detect_exit_code
detect_modules_needing_tags
detect_exit_code=$?
echo ""
echo "❓ Do you want to proceed with creating and pushing these release tags?"
echo " This will create git tags and push them to the remote repository."
echo ""
read -p "Continue? [y/N]: " -r response
case $detect_exit_code in
$EXIT_NO_ACTION_NEEDED)
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
finalize_json_output "$@"
else
log "SUCCESS" "✨ No modules need tagging. All done!"
fi
exit $EXIT_SUCCESS
case "$response" in
[yY] | [yY][eE][sS])
echo ""
create_and_push_tags
;;
$EXIT_ERROR)
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "scan_failed"')
finalize_json_output "$@"
fi
exit $EXIT_ERROR
*)
echo ""
echo "🚫 Operation cancelled by user"
exit 0
;;
esac
if [[ "$AUTO_APPROVE" != "true" && "$OUTPUT_FORMAT" != "json" && "$DRY_RUN" != "true" ]]; then
echo ""
log "INFO" "❓ Do you want to proceed with creating and pushing these release tags?"
log "INFO" " This will create git tags and push them to the remote repository."
echo ""
read -p "Continue? [y/N]: " -r response
case "$response" in
[yY] | [yY][eE][sS])
echo ""
;;
*)
echo ""
log "INFO" "🚫 Operation cancelled by user"
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "cancelled_by_user"')
finalize_json_output "$@"
fi
exit $EXIT_SUCCESS
;;
esac
fi
local create_exit_code
create_and_push_tags
create_exit_code=$?
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
finalize_json_output "$@"
fi
exit $create_exit_code
}
main "$@"
Executable → Regular
View File
+1 -1
View File
@@ -247,8 +247,8 @@ export const runTerraformApply = async <TVars extends TerraformVariables>(
"-compact-warnings",
"-input=false",
"-auto-approve",
"-no-color",
"-state",
"-no-color",
stateFile,
],
{