mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32246a99c1 | |||
| bb667d2209 | |||
| f08bb30b53 | |||
| 32b039a838 | |||
| 4dcaea7bf9 | |||
| c2bc5cd314 | |||
| c73b923e40 | |||
| 08ed594bfd | |||
| fd074a5643 | |||
| 40863c0aa7 | |||
| d9b223ac3c | |||
| 1749f9ca05 |
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Run check.sh
|
||||
run: |
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@main
|
||||
- name: Set up Bun
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -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.35.4
|
||||
with:
|
||||
config: .github/typos.toml
|
||||
validate-readme-files:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
needs: validate-style
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Authenticate with Google Cloud
|
||||
uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5
|
||||
with:
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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
|
||||
}
|
||||
+1
-1
@@ -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 := validateCoderResourceReadmeBody(testBody)
|
||||
errs := validateCoderModuleReadmeBody(testBody)
|
||||
for _, e := range errs {
|
||||
t.Error(e)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -17,6 +15,7 @@ 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
|
||||
@@ -25,11 +24,21 @@ 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"`
|
||||
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",
|
||||
}
|
||||
|
||||
// coderResourceReadme represents a README describing a Terraform resource used
|
||||
@@ -42,6 +51,17 @@ 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")
|
||||
@@ -67,7 +87,7 @@ func validateCoderResourceIconURL(iconURL string) []error {
|
||||
return []error{xerrors.New("icon URL cannot be empty")}
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var errs []error
|
||||
|
||||
// If the URL does not have a relative path.
|
||||
if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") {
|
||||
@@ -98,7 +118,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.
|
||||
invalidTags := []string{}
|
||||
var invalidTags []string
|
||||
for _, t := range tags {
|
||||
if t != url.QueryEscape(t) {
|
||||
invalidTags = append(invalidTags, t)
|
||||
@@ -111,119 +131,50 @@ func validateCoderResourceTags(tags []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCoderResourceReadmeBody(body string) []error {
|
||||
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)}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
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
|
||||
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))
|
||||
}
|
||||
|
||||
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 validateCoderResourceIconURL(fm.IconURL) {
|
||||
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"))
|
||||
for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderResourceReadme(rm coderResourceReadme) []error {
|
||||
var errs []error
|
||||
|
||||
for _, err := range validateCoderResourceReadmeBody(rm.body) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", 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
|
||||
}
|
||||
|
||||
yml := coderResourceFrontmatter{}
|
||||
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
|
||||
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
|
||||
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
return coderResourceReadme{
|
||||
@@ -234,13 +185,17 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) {
|
||||
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) ([]coderResourceReadme, error) {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
|
||||
}
|
||||
|
||||
resources := map[string]coderResourceReadme{}
|
||||
var yamlParsingErrs []error
|
||||
for _, rm := range rms {
|
||||
p, err := parseCoderResourceReadme(resourceType, rm)
|
||||
if err != nil {
|
||||
yamlParsingErrs = append(yamlParsingErrs, err)
|
||||
p, errs := parseCoderResourceReadme(resourceType, rm)
|
||||
if len(errs) != 0 {
|
||||
yamlParsingErrs = append(yamlParsingErrs, errs...)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -253,30 +208,27 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
|
||||
}
|
||||
}
|
||||
|
||||
yamlValidationErrors := []error{}
|
||||
for _, readme := range resources {
|
||||
errs := validateCoderResourceReadme(readme)
|
||||
if len(errs) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errs...)
|
||||
}
|
||||
var serialized []coderResourceReadme
|
||||
for _, r := range resources {
|
||||
serialized = append(serialized, r)
|
||||
}
|
||||
if len(yamlValidationErrors) != 0 {
|
||||
return nil, validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlValidationErrors,
|
||||
}
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
slices.SortFunc(serialized, func(r1 coderResourceReadme, r2 coderResourceReadme) int {
|
||||
return strings.Compare(r1.filePath, r2.filePath)
|
||||
})
|
||||
return serialized, nil
|
||||
}
|
||||
|
||||
// Todo: Need to beef up this function by grabbing each image/video URL from
|
||||
// the body's AST.
|
||||
func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error {
|
||||
func validateCoderResourceRelativeURLs(_ []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
|
||||
@@ -325,27 +277,3 @@ 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(), "processing README files", "num_files", len(allReadmeFiles))
|
||||
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "processed 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
@@ -19,11 +19,16 @@ 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
|
||||
@@ -50,6 +55,22 @@ 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.
|
||||
@@ -58,7 +79,7 @@ func validateContributorSupportEmail(email *string) []error {
|
||||
return nil
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var errs []error
|
||||
|
||||
username, server, ok := strings.Cut(*email, "@")
|
||||
if !ok {
|
||||
@@ -119,7 +140,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
|
||||
return []error{xerrors.New("avatar URL must be omitted or non-empty string")}
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var 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))
|
||||
@@ -145,7 +166,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
|
||||
}
|
||||
|
||||
func validateContributorReadme(rm contributorProfileReadme) []error {
|
||||
allErrs := []error{}
|
||||
var allErrs []error
|
||||
|
||||
if err := validateContributorDisplayName(rm.frontmatter.DisplayName); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
@@ -153,6 +174,9 @@ 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))
|
||||
}
|
||||
@@ -170,15 +194,24 @@ 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{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
|
||||
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
|
||||
}
|
||||
|
||||
yml := contributorProfileFrontmatter{}
|
||||
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
|
||||
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
|
||||
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
return contributorProfileReadme{
|
||||
@@ -190,11 +223,11 @@ func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
|
||||
|
||||
func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfileReadme, error) {
|
||||
profilesByNamespace := map[string]contributorProfileReadme{}
|
||||
yamlParsingErrors := []error{}
|
||||
var yamlParsingErrors []error
|
||||
for _, rm := range readmeEntries {
|
||||
p, err := parseContributorProfile(rm)
|
||||
if err != nil {
|
||||
yamlParsingErrors = append(yamlParsingErrors, err)
|
||||
p, errs := parseContributorProfile(rm)
|
||||
if len(errs) != 0 {
|
||||
yamlParsingErrors = append(yamlParsingErrors, errs...)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -211,7 +244,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
|
||||
}
|
||||
}
|
||||
|
||||
yamlValidationErrors := []error{}
|
||||
var yamlValidationErrors []error
|
||||
for _, p := range profilesByNamespace {
|
||||
if errors := validateContributorReadme(p); len(errors) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errors...)
|
||||
@@ -234,8 +267,8 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allReadmeFiles := []readme{}
|
||||
errs := []error{}
|
||||
var allReadmeFiles []readme
|
||||
var errs []error
|
||||
dirPath := ""
|
||||
for _, e := range dirEntries {
|
||||
if !e.IsDir() {
|
||||
|
||||
@@ -31,7 +31,11 @@ func main() {
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
err = validateAllCoderResourceFilesOfType("modules")
|
||||
err = validateAllCoderModules()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
err = validateAllCoderTemplates()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
@@ -39,7 +40,9 @@ const (
|
||||
|
||||
var (
|
||||
supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
|
||||
// Matches markdown headers, must be at the beginning of a line, such as "# " or "### ".
|
||||
// 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 1–6 to be valid). The function has checks to see if the level is correct.
|
||||
readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`)
|
||||
)
|
||||
|
||||
@@ -168,3 +171,25 @@ 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
|
||||
}
|
||||
|
||||
@@ -10,18 +10,21 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images")
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
|
||||
|
||||
// validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all
|
||||
// expected file conventions
|
||||
func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
subDir, err := os.Stat(dirPath)
|
||||
resourceDir, 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 !subDir.IsDir() {
|
||||
if !resourceDir.IsDir() {
|
||||
return []error{xerrors.Errorf("%q: path is not a directory", dirPath)}
|
||||
}
|
||||
|
||||
@@ -30,10 +33,11 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
return []error{addFilePathToError(dirPath, err)}
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var errs []error
|
||||
for _, f := range files {
|
||||
// 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.
|
||||
// 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.
|
||||
if !f.IsDir() || f.Name() == ".coder" {
|
||||
continue
|
||||
}
|
||||
@@ -59,49 +63,53 @@ 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 {
|
||||
userDirs, err := os.ReadDir(rootRegistryPath)
|
||||
namespaceDirs, err := os.ReadDir(rootRegistryPath)
|
||||
if err != nil {
|
||||
return []error{err}
|
||||
}
|
||||
|
||||
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))
|
||||
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))
|
||||
continue
|
||||
}
|
||||
|
||||
contributorReadmePath := path.Join(dirPath, "README.md")
|
||||
contributorReadmePath := path.Join(namespacePath, "README.md")
|
||||
if _, err := os.Stat(contributorReadmePath); err != nil {
|
||||
allErrs = append(allErrs, err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(dirPath)
|
||||
files, err := os.ReadDir(namespacePath)
|
||||
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 scoped to user namespaces.
|
||||
// TODO: Decide if there's anything more formal that we want to ensure about non-directories at the top
|
||||
// level of each user namespace.
|
||||
if !f.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
segment := f.Name()
|
||||
filePath := path.Join(dirPath, segment)
|
||||
filePath := path.Join(namespacePath, 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 slices.Contains(supportedResourceTypes, segment) {
|
||||
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
|
||||
allErrs = append(allErrs, errs...)
|
||||
}
|
||||
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
|
||||
allErrs = append(allErrs, errs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +117,9 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
display_name: Cursor CLI
|
||||
icon: ../../../../.icons/cursor.svg
|
||||
description: Run Cursor CLI agent in your workspace (no AgentAPI)
|
||||
verified: true
|
||||
tags: [agent, cursor, ai, cli]
|
||||
---
|
||||
|
||||
# Cursor CLI
|
||||
|
||||
Run the Cursor Coding Agent in your workspace using the Cursor CLI directly.
|
||||
|
||||
A full example with MCP, rules, and pre/post install scripts:
|
||||
|
||||
```tf
|
||||
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Build a Minesweeper in Python."
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "1.0.31"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
module "cursor_cli" {
|
||||
source = "registry.coder.com/coder-labs/cursor-cli/coder"
|
||||
version = "0.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
|
||||
# Optional
|
||||
install_cursor_cli = true
|
||||
force = true
|
||||
model = "gpt-5"
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
|
||||
# Minimal MCP server (writes `folder/.cursor/mcp.json`):
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
playwright = {
|
||||
command = "npx"
|
||||
args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated", "--no-sandbox"]
|
||||
}
|
||||
desktop-commander = {
|
||||
command = "npx"
|
||||
args = ["-y", "@wonderwhy-er/desktop-commander"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Use a pre_install_script to install the CLI
|
||||
pre_install_script = <<-EOT
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
EOT
|
||||
|
||||
# Use post_install_script to wait for the repo to be ready
|
||||
post_install_script = <<-EOT
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
TARGET="$${FOLDER}/.git/config"
|
||||
echo "[cursor-cli] waiting for $${TARGET}..."
|
||||
for i in $(seq 1 600); do
|
||||
[ -f "$TARGET" ] && { echo "ready"; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
echo "timeout waiting for $${TARGET}" >&2
|
||||
EOT
|
||||
|
||||
# Provide a map of file name to content; files are written to `folder/.cursor/rules/<name>`.
|
||||
rules_files = {
|
||||
"python.mdc" = <<-EOT
|
||||
---
|
||||
description: RPC Service boilerplate
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use our internal RPC pattern when defining services
|
||||
- Always use snake_case for service names.
|
||||
|
||||
@service-template.ts
|
||||
EOT
|
||||
|
||||
"frontend.mdc" = <<-EOT
|
||||
---
|
||||
description: RPC Service boilerplate
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use our internal RPC pattern when defining services
|
||||
- Always use snake_case for service names.
|
||||
|
||||
@service-template.ts
|
||||
EOT
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> A `.cursor` directory will be created in the specified `folder`, containing the MCP configuration, rules.
|
||||
> To use this module with tasks, please pass the API Key obtained from Cursor to the `api_key` variable. To obtain the api key follow the instructions [here](https://docs.cursor.com/en/cli/reference/authentication#step-1%3A-generate-an-api-key)
|
||||
|
||||
## References
|
||||
|
||||
- See Cursor CLI docs: `https://docs.cursor.com/en/cli/overview`
|
||||
- For MCP project config, see `https://docs.cursor.com/en/context/mcp#using-mcp-json`. This module writes your `mcp_json` into `folder/.cursor/mcp.json`.
|
||||
- For Rules, see `https://docs.cursor.com/en/context/rules#project-rules`. Provide `rules_files` (map of file name to content) to populate `folder/.cursor/rules/`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Ensure the CLI is installed (enable `install_cursor_cli = true` or preinstall it in your image)
|
||||
- Logs are written to `~/.cursor-cli-module/`
|
||||
@@ -0,0 +1,152 @@
|
||||
run "test_cursor_cli_basic" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
folder = "/home/coder/projects"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.status_slug.name == "CODER_MCP_APP_STATUS_SLUG"
|
||||
error_message = "Status slug environment variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.status_slug.value == "cursorcli"
|
||||
error_message = "Status slug value should be 'cursorcli'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.folder == "/home/coder/projects"
|
||||
error_message = "Folder variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agent_id == "test-agent-123"
|
||||
error_message = "Agent ID variable should be set correctly"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_api_key" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-456"
|
||||
folder = "/home/coder/workspace"
|
||||
api_key = "test-api-key-123"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.cursor_api_key[0].name == "CURSOR_API_KEY"
|
||||
error_message = "Cursor API key environment variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.cursor_api_key[0].value == "test-api-key-123"
|
||||
error_message = "Cursor API key value should match the input"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_custom_options" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-789"
|
||||
folder = "/home/coder/custom"
|
||||
order = 5
|
||||
group = "development"
|
||||
icon = "/icon/custom.svg"
|
||||
model = "sonnet-4"
|
||||
ai_prompt = "Help me write better code"
|
||||
force = false
|
||||
install_cursor_cli = false
|
||||
install_agentapi = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.order == 5
|
||||
error_message = "Order variable should be set to 5"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.group == "development"
|
||||
error_message = "Group variable should be set to 'development'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.icon == "/icon/custom.svg"
|
||||
error_message = "Icon variable should be set to custom icon"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.model == "sonnet-4"
|
||||
error_message = "Model variable should be set to 'sonnet-4'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.ai_prompt == "Help me write better code"
|
||||
error_message = "AI prompt variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.force == false
|
||||
error_message = "Force variable should be set to false"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_mcp_and_rules" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-mcp"
|
||||
folder = "/home/coder/mcp-test"
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
test = {
|
||||
command = "test-server"
|
||||
args = ["--config", "test.json"]
|
||||
}
|
||||
}
|
||||
})
|
||||
rules_files = {
|
||||
"general.md" = "# General coding rules\n- Write clean code\n- Add comments"
|
||||
"security.md" = "# Security rules\n- Never commit secrets\n- Validate inputs"
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.mcp != null
|
||||
error_message = "MCP configuration should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.rules_files != null
|
||||
error_message = "Rules files should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(var.rules_files) == 2
|
||||
error_message = "Should have 2 rules files"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-scripts"
|
||||
folder = "/home/coder/scripts"
|
||||
pre_install_script = "echo 'Pre-install script'"
|
||||
post_install_script = "echo 'Post-install script'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pre_install_script == "echo 'Pre-install script'"
|
||||
error_message = "Pre-install script should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.post_install_script == "echo 'Post-install script'"
|
||||
error_message = "Post-install script should be set correctly"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
|
||||
import { execContainer, runTerraformInit, writeFileContainer } from "~test";
|
||||
import {
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
loadTestFile,
|
||||
setup as setupUtil
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
import { setupContainer, writeExecutable } from "../../../coder/modules/agentapi/test-util";
|
||||
|
||||
let cleanupFns: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (fn: () => Promise<void>) => cleanupFns.push(fn);
|
||||
|
||||
afterEach(async () => {
|
||||
const fns = cleanupFns.slice().reverse();
|
||||
cleanupFns = [];
|
||||
for (const fn of fns) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipCursorCliMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
enable_agentapi: "true",
|
||||
install_cursor_cli: props?.skipCursorCliMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
folder: projectDir,
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipCursorCliMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/cursor-agent",
|
||||
content: await loadTestFile(import.meta.dir, "cursor-cli-mock.sh"),
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(180 * 1000);
|
||||
|
||||
describe("cursor-cli", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("agentapi-happy-path", async () => {
|
||||
const { id } = await setup({});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("agentapi-mcp-json", async () => {
|
||||
const mcpJson = '{"mcpServers": {"test": {"command": "test-cmd", "type": "stdio"}}}';
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
mcp: mcpJson,
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const mcpContent = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat '/home/coder/project/.cursor/mcp.json'`,
|
||||
]);
|
||||
expect(mcpContent.exitCode).toBe(0);
|
||||
expect(mcpContent.stdout).toContain("mcpServers");
|
||||
expect(mcpContent.stdout).toContain("test");
|
||||
expect(mcpContent.stdout).toContain("test-cmd");
|
||||
expect(mcpContent.stdout).toContain("/tmp/mcp-hack.sh");
|
||||
expect(mcpContent.stdout).toContain("coder");
|
||||
});
|
||||
|
||||
test("agentapi-rules-files", async () => {
|
||||
const rulesContent = "Always use TypeScript";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
rules_files: JSON.stringify({ "typescript.md": rulesContent }),
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const rulesFile = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat '/home/coder/project/.cursor/rules/typescript.md'`,
|
||||
]);
|
||||
expect(rulesFile.exitCode).toBe(0);
|
||||
expect(rulesFile.stdout).toContain(rulesContent);
|
||||
});
|
||||
|
||||
test("agentapi-api-key", async () => {
|
||||
const apiKey = "test-cursor-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
api_key: apiKey,
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const envCheck = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`env | grep CURSOR_API_KEY || echo "CURSOR_API_KEY not found"`,
|
||||
]);
|
||||
expect(envCheck.stdout).toContain("CURSOR_API_KEY");
|
||||
});
|
||||
|
||||
test("agentapi-model-and-force-flags", async () => {
|
||||
const model = "sonnet-4";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
model: model,
|
||||
force: "true",
|
||||
ai_prompt: "test prompt",
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/agentapi-start.log || cat /home/coder/.cursor-cli-module/start.log || true",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`-m ${model}`);
|
||||
expect(startLog.stdout).toContain("-f");
|
||||
expect(startLog.stdout).toContain("test prompt");
|
||||
});
|
||||
|
||||
test("agentapi-pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'cursor-pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'cursor-post-install-script'",
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const preInstallLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/pre_install.log || true",
|
||||
]);
|
||||
expect(preInstallLog.stdout).toContain("cursor-pre-install-script");
|
||||
|
||||
const postInstallLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/post_install.log || true",
|
||||
]);
|
||||
expect(postInstallLog.stdout).toContain("cursor-post-install-script");
|
||||
});
|
||||
|
||||
test("agentapi-folder-variable", async () => {
|
||||
const folder = "/tmp/cursor-test-folder";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
folder: folder,
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const installLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/install.log || true",
|
||||
]);
|
||||
expect(installLog.stdout).toContain(folder);
|
||||
});
|
||||
|
||||
test("install-test-cursor-cli-latest", async () => {
|
||||
const { id } = await setup({
|
||||
skipCursorCliMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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."
|
||||
}
|
||||
|
||||
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.5.0"
|
||||
}
|
||||
|
||||
variable "force" {
|
||||
type = bool
|
||||
description = "Force allow commands unless explicitly denied"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "model" {
|
||||
type = string
|
||||
description = "Model to use (e.g., sonnet-4, sonnet-4-thinking, gpt-5)"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "AI prompt/task passed to cursor-agent."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "api_key" {
|
||||
type = string
|
||||
description = "API key for Cursor CLI."
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "Workspace-specific MCP JSON to write to folder/.cursor/mcp.json. See https://docs.cursor.com/en/context/mcp#using-mcp-json"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "rules_files" {
|
||||
type = map(string)
|
||||
description = "Optional map of rule file name to content. Files will be written to folder/.cursor/rules/<name>. See https://docs.cursor.com/en/context/rules#project-rules"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Optional script to run before installing Cursor CLI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Optional script to run after installing Cursor CLI."
|
||||
default = null
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "cursorcli"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".cursor-cli-module"
|
||||
}
|
||||
|
||||
# Expose status slug and API key to the agent environment
|
||||
resource "coder_env" "status_slug" {
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_MCP_APP_STATUS_SLUG"
|
||||
value = local.app_slug
|
||||
}
|
||||
|
||||
resource "coder_env" "cursor_api_key" {
|
||||
count = var.api_key != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "CURSOR_API_KEY"
|
||||
value = var.api_key
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
|
||||
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
|
||||
cli_app_display_name = "Cursor CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
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
|
||||
ARG_FORCE='${var.force}' \
|
||||
ARG_MODEL='${var.model}' \
|
||||
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_MODULE_DIR_NAME='${local.module_dir_name}' \
|
||||
ARG_FOLDER='${var.folder}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
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_INSTALL='${var.install_cursor_cli}' \
|
||||
ARG_WORKSPACE_MCP_JSON='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
|
||||
ARG_WORKSPACE_RULES_JSON='${var.rules_files != null ? base64encode(jsonencode(var.rules_files)) : ""}' \
|
||||
ARG_MODULE_DIR_NAME='${local.module_dir_name}' \
|
||||
ARG_FOLDER='${var.folder}' \
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Inputs
|
||||
ARG_INSTALL=${ARG_INSTALL:-true}
|
||||
ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module}
|
||||
ARG_FOLDER=${ARG_FOLDER:-$HOME}
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG=${ARG_CODER_MCP_APP_STATUS_SLUG:-}
|
||||
|
||||
mkdir -p "$HOME/$ARG_MODULE_DIR_NAME"
|
||||
|
||||
ARG_WORKSPACE_MCP_JSON=$(echo -n "$ARG_WORKSPACE_MCP_JSON" | base64 -d)
|
||||
ARG_WORKSPACE_RULES_JSON=$(echo -n "$ARG_WORKSPACE_RULES_JSON" | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "install: $ARG_INSTALL"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "coder_mcp_app_status_slug: $ARG_CODER_MCP_APP_STATUS_SLUG"
|
||||
echo "module_dir_name: $ARG_MODULE_DIR_NAME"
|
||||
echo "--------------------------------"
|
||||
|
||||
# Install Cursor via official installer if requested
|
||||
function install_cursor_cli() {
|
||||
if [ "$ARG_INSTALL" = "true" ]; then
|
||||
echo "Installing Cursor via official installer..."
|
||||
set +e
|
||||
curl https://cursor.com/install -fsS | bash 2>&1
|
||||
CURL_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ $CURL_EXIT -ne 0 ]; then
|
||||
echo "Cursor installer failed with exit code $CURL_EXIT"
|
||||
fi
|
||||
|
||||
# Ensure binaries are discoverable; create stable symlink to cursor-agent
|
||||
CANDIDATES=(
|
||||
"$(command -v cursor-agent || true)"
|
||||
"$HOME/.cursor/bin/cursor-agent"
|
||||
)
|
||||
FOUND_BIN=""
|
||||
for c in "${CANDIDATES[@]}"; do
|
||||
if [ -n "$c" ] && [ -x "$c" ]; then
|
||||
FOUND_BIN="$c"
|
||||
break
|
||||
fi
|
||||
done
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
if [ -n "$FOUND_BIN" ]; then
|
||||
ln -sf "$FOUND_BIN" "$HOME/.local/bin/cursor-agent"
|
||||
fi
|
||||
echo "Installed cursor-agent at: $(command -v cursor-agent || true) (resolved: $FOUND_BIN)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Write MCP config to user's home if provided (ARG_FOLDER/.cursor/mcp.json)
|
||||
function write_mcp_config() {
|
||||
TARGET_DIR="$ARG_FOLDER/.cursor"
|
||||
TARGET_FILE="$TARGET_DIR/mcp.json"
|
||||
mkdir -p "$TARGET_DIR"
|
||||
|
||||
CURSOR_MCP_HACK_SCRIPT=$(
|
||||
cat << EOF
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# --- Set environment variables ---
|
||||
export CODER_MCP_APP_STATUS_SLUG="${ARG_CODER_MCP_APP_STATUS_SLUG}"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
export CODER_AGENT_URL="${CODER_AGENT_URL}"
|
||||
export CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN}"
|
||||
|
||||
# --- Launch the MCP server ---
|
||||
exec coder exp mcp server
|
||||
EOF
|
||||
)
|
||||
echo "$CURSOR_MCP_HACK_SCRIPT" > "/tmp/mcp-hack.sh"
|
||||
chmod +x /tmp/mcp-hack.sh
|
||||
|
||||
CODER_MCP=$(
|
||||
cat << EOF
|
||||
{
|
||||
"coder": {
|
||||
"args": [],
|
||||
"command": "/tmp/mcp-hack.sh",
|
||||
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
|
||||
"name": "Coder",
|
||||
"timeout": 3000,
|
||||
"type": "stdio",
|
||||
"trust": true
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "${ARG_WORKSPACE_MCP_JSON:-{}}" | jq --argjson base "$CODER_MCP" \
|
||||
'.mcpServers = ((.mcpServers // {}) + $base)' > "$TARGET_FILE"
|
||||
echo "Wrote workspace MCP to $TARGET_FILE"
|
||||
}
|
||||
|
||||
# Write rules files to user's home (FOLDER/.cursor/rules)
|
||||
function write_rules_file() {
|
||||
if [ -n "$ARG_WORKSPACE_RULES_JSON" ]; then
|
||||
RULES_DIR="$ARG_FOLDER/.cursor/rules"
|
||||
mkdir -p "$RULES_DIR"
|
||||
echo "$ARG_WORKSPACE_RULES_JSON" | jq -r 'to_entries[] | @base64' | while read -r entry; do
|
||||
_jq() { echo "${entry}" | base64 -d | jq -r ${1}; }
|
||||
NAME=$(_jq '.key')
|
||||
CONTENT=$(_jq '.value')
|
||||
echo "$CONTENT" > "$RULES_DIR/$NAME"
|
||||
echo "Wrote rule: $RULES_DIR/$NAME"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
install_cursor_cli
|
||||
write_mcp_config
|
||||
write_rules_file
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d)
|
||||
ARG_FORCE=${ARG_FORCE:-false}
|
||||
ARG_MODEL=${ARG_MODEL:-}
|
||||
ARG_OUTPUT_FORMAT=${ARG_OUTPUT_FORMAT:-json}
|
||||
ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module}
|
||||
ARG_FOLDER=${ARG_FOLDER:-$HOME}
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "install: $ARG_INSTALL"
|
||||
echo "version: $ARG_VERSION"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "ai_prompt: $ARG_AI_PROMPT"
|
||||
echo "force: $ARG_FORCE"
|
||||
echo "model: $ARG_MODEL"
|
||||
echo "output_format: $ARG_OUTPUT_FORMAT"
|
||||
echo "module_dir_name: $ARG_MODULE_DIR_NAME"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "--------------------------------"
|
||||
|
||||
mkdir -p "$HOME/$ARG_MODULE_DIR_NAME"
|
||||
|
||||
# Find cursor agent cli
|
||||
if command_exists cursor-agent; then
|
||||
CURSOR_CMD=cursor-agent
|
||||
elif [ -x "$HOME/.local/bin/cursor-agent" ]; then
|
||||
CURSOR_CMD="$HOME/.local/bin/cursor-agent"
|
||||
else
|
||||
echo "Error: cursor-agent not found. Install it or set install_cursor_cli=true."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure working directory exists
|
||||
if [ -d "$ARG_FOLDER" ]; then
|
||||
cd "$ARG_FOLDER"
|
||||
else
|
||||
mkdir -p "$ARG_FOLDER"
|
||||
cd "$ARG_FOLDER"
|
||||
fi
|
||||
|
||||
ARGS=()
|
||||
|
||||
# global flags
|
||||
if [ -n "$ARG_MODEL" ]; then
|
||||
ARGS+=("-m" "$ARG_MODEL")
|
||||
fi
|
||||
if [ "$ARG_FORCE" = "true" ]; then
|
||||
ARGS+=("-f")
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_AI_PROMPT" ]; then
|
||||
printf "AI prompt provided\n"
|
||||
ARGS+=("Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_AI_PROMPT")
|
||||
fi
|
||||
|
||||
# Log and run in background, redirecting all output to the log file
|
||||
printf "Running: %q %s\n" "$CURSOR_CMD" "$(printf '%q ' "${ARGS[@]}")"
|
||||
|
||||
agentapi server --type cursor --term-width 67 --term-height 1190 -- "$CURSOR_CMD" "${ARGS[@]}"
|
||||
+2
-2
@@ -2,13 +2,13 @@
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "gemini version v2.5.0"
|
||||
echo "cursor-agent version v2.5.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - gemini-mock"
|
||||
echo "$(date) - cursor-agent-mock"
|
||||
sleep 15
|
||||
done
|
||||
@@ -1,36 +1,41 @@
|
||||
---
|
||||
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://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.
|
||||
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.
|
||||
|
||||
```tf
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.0.1"
|
||||
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"
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
## Usage Example
|
||||
## Examples
|
||||
|
||||
- Example 1:
|
||||
### Basic setup
|
||||
|
||||
```tf
|
||||
variable "gemini_api_key" {
|
||||
@@ -40,39 +45,97 @@ variable "gemini_api_key" {
|
||||
}
|
||||
|
||||
module "gemini" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.0.1"
|
||||
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:`"
|
||||
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"
|
||||
}
|
||||
```
|
||||
|
||||
## How it Works
|
||||
This basic setup will:
|
||||
|
||||
- **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)
|
||||
- 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
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
> [!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:\` "
|
||||
The module creates log files in the workspace's `~/.gemini-module` directory for debugging purposes.
|
||||
|
||||
## References
|
||||
|
||||
- [Gemini CLI Documentation](https://ai.google.dev/gemini-api/docs/cli)
|
||||
- [Gemini CLI Documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/index.md)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/ai-coder)
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
@@ -54,10 +53,24 @@ 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: await loadTestFile(import.meta.dir, "gemini-mock.sh"),
|
||||
content: geminiMockContent,
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
@@ -70,7 +83,7 @@ describe("gemini", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
test("agent-api", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
@@ -117,7 +130,7 @@ describe("gemini", async () => {
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(id, "/home/coder/.gemini-module/agentapi-start.log");
|
||||
expect(resp).toContain("gemini_api_key provided !");
|
||||
expect(resp).toContain("Using direct Gemini API with API key");
|
||||
});
|
||||
|
||||
test("use-vertexai", async () => {
|
||||
@@ -197,6 +210,20 @@ 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);
|
||||
|
||||
@@ -74,14 +74,14 @@ variable "use_vertexai" {
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
description = "Whether to install AgentAPI for web UI and task automation."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.3.0"
|
||||
default = "v0.2.3"
|
||||
}
|
||||
|
||||
variable "gemini_model" {
|
||||
@@ -102,12 +102,10 @@ variable "post_install_script" {
|
||||
default = null
|
||||
}
|
||||
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
variable "task_prompt" {
|
||||
type = string
|
||||
description = "Task prompt for automated Gemini execution"
|
||||
default = ""
|
||||
description = "Initial prompt for the Gemini CLI"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
variable "additional_extensions" {
|
||||
@@ -122,12 +120,24 @@ 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"
|
||||
@@ -181,22 +191,7 @@ module "agentapi" {
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
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
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
@@ -209,7 +204,23 @@ 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_INSTRUCTION_PROMPT='${base64encode(var.gemini_system_prompt)}' \
|
||||
GEMINI_SYSTEM_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,7 +2,6 @@
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
# Function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
@@ -12,7 +11,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_INSTRUCTION_PROMPT=$(echo -n "$GEMINI_INSTRUCTION_PROMPT" | base64 -d)
|
||||
GEMINI_SYSTEM_PROMPT=$(echo -n "$GEMINI_SYSTEM_PROMPT" | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
|
||||
@@ -23,7 +22,6 @@ 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
|
||||
@@ -52,24 +50,15 @@ 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
|
||||
@@ -108,7 +97,6 @@ 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"
|
||||
@@ -116,10 +104,7 @@ 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"
|
||||
@@ -133,14 +118,13 @@ 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_instruction_prompt_if_exists() {
|
||||
if [ -n "${GEMINI_INSTRUCTION_PROMPT:-}" ]; then
|
||||
function add_system_prompt_if_exists() {
|
||||
if [ -n "${GEMINI_SYSTEM_PROMPT:-}" ]; then
|
||||
if [ -d "${GEMINI_START_DIRECTORY}" ]; then
|
||||
printf "Directory '%s' exists. Changing to it.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
cd "${GEMINI_START_DIRECTORY}" || {
|
||||
@@ -160,16 +144,21 @@ function add_instruction_prompt_if_exists() {
|
||||
fi
|
||||
touch GEMINI.md
|
||||
printf "Setting GEMINI.md\n"
|
||||
echo "${GEMINI_INSTRUCTION_PROMPT}" > GEMINI.md
|
||||
echo "${GEMINI_SYSTEM_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_instruction_prompt_if_exists
|
||||
add_system_prompt_if_exists
|
||||
configure_mcp
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
# Load shell environment
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
command_exists() {
|
||||
@@ -15,7 +16,8 @@ fi
|
||||
|
||||
printf "Version: %s\n" "$(gemini --version)"
|
||||
|
||||
GEMINI_TASK_PROMPT=$(echo -n "$GEMINI_TASK_PROMPT" | base64 -d)
|
||||
MODULE_DIR="$HOME/.gemini-module"
|
||||
mkdir -p "$MODULE_DIR"
|
||||
|
||||
if command_exists gemini; then
|
||||
printf "Gemini is installed\n"
|
||||
@@ -43,20 +45,30 @@ else
|
||||
fi
|
||||
|
||||
if [ -n "$GEMINI_TASK_PROMPT" ]; then
|
||||
printf "Running the task prompt %s\n" "$GEMINI_TASK_PROMPT"
|
||||
printf "Running automated task: %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 "No task prompt given.\n"
|
||||
printf "Starting Gemini CLI in interactive mode.\n"
|
||||
GEMINI_ARGS=()
|
||||
fi
|
||||
|
||||
if [ -n "$GEMINI_API_KEY" ]; then
|
||||
printf "gemini_api_key provided !\n"
|
||||
else
|
||||
printf "gemini_api_key not provided\n"
|
||||
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
|
||||
|
||||
# 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[@]}"
|
||||
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
|
||||
else
|
||||
printf "No API key provided (neither GEMINI_API_KEY nor GOOGLE_API_KEY)\n"
|
||||
fi
|
||||
|
||||
agentapi server --term-width 67 --term-height 1190 -- \
|
||||
bash -c "$(printf '%q ' gemini "${GEMINI_ARGS[@]}")"
|
||||
@@ -8,6 +8,10 @@ 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.
|
||||
@@ -18,7 +22,22 @@ This template builds a custom Docker image from the included Dockerfile, allowin
|
||||
|
||||
### Infrastructure
|
||||
|
||||
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
#### 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:
|
||||
|
||||
```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](./main.tf) into the template editor and save.
|
||||
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.
|
||||
|
||||
Alternatively, you can use the Coder CLI to [push the template](https://coder.com/docs/reference/cli/templates_push)
|
||||
|
||||
|
||||
@@ -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.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
@@ -28,7 +28,6 @@ module "claude-code" {
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js and npm must be installed in your workspace to install Claude Code
|
||||
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
|
||||
|
||||
The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces.
|
||||
@@ -84,7 +83,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.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
@@ -102,7 +101,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.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
|
||||
@@ -111,7 +111,7 @@ locals {
|
||||
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
|
||||
agentapi_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-start.sh"))
|
||||
agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
|
||||
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.js"))
|
||||
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.sh"))
|
||||
claude_code_app_slug = "ccw"
|
||||
}
|
||||
|
||||
@@ -129,6 +129,21 @@ resource "coder_script" "claude_code" {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
function install_claude_code_cli() {
|
||||
echo "Installing Claude Code via official installer"
|
||||
set +e
|
||||
curl -fsSL claude.ai/install.sh | bash -s -- "${var.claude_code_version}" 2>&1
|
||||
CURL_EXIT=$${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ $CURL_EXIT -ne 0 ]; then
|
||||
echo "Claude Code installer failed with exit code $$CURL_EXIT"
|
||||
fi
|
||||
|
||||
# Ensure binaries are discoverable.
|
||||
export PATH="~/.local/bin:$PATH"
|
||||
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
|
||||
}
|
||||
|
||||
if [ ! -d "${local.workdir}" ]; then
|
||||
echo "Warning: The specified folder '${local.workdir}' does not exist."
|
||||
echo "Creating the folder..."
|
||||
@@ -143,37 +158,7 @@ resource "coder_script" "claude_code" {
|
||||
fi
|
||||
|
||||
if [ "${var.install_claude_code}" = "true" ]; then
|
||||
if ! command_exists npm; then
|
||||
echo "npm not found, checking for Node.js installation..."
|
||||
if ! command_exists node; then
|
||||
echo "Node.js not found, installing Node.js via NVM..."
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
if [ ! -d "$NVM_DIR" ]; then
|
||||
mkdir -p "$NVM_DIR"
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
else
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
nvm alias default node
|
||||
|
||||
echo "Node.js installed: $(node --version)"
|
||||
echo "npm installed: $(npm --version)"
|
||||
else
|
||||
echo "Node.js is installed but npm is not available. Please install npm manually."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "Installing Claude Code..."
|
||||
npm install -g @anthropic-ai/claude-code@${var.claude_code_version}
|
||||
fi
|
||||
|
||||
if ! command_exists node; then
|
||||
echo "Error: Node.js is not installed. Please install Node.js manually."
|
||||
exit 1
|
||||
install_claude_code_cli
|
||||
fi
|
||||
|
||||
# Install AgentAPI if enabled
|
||||
@@ -214,7 +199,7 @@ resource "coder_script" "claude_code" {
|
||||
|
||||
echo -n "${local.agentapi_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-start.sh"
|
||||
echo -n "${local.agentapi_wait_for_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.js"
|
||||
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.sh"
|
||||
chmod +x "$module_path/scripts/agentapi-start.sh"
|
||||
chmod +x "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
|
||||
@@ -292,4 +277,4 @@ resource "coder_ai_task" "claude_code" {
|
||||
sidebar_app {
|
||||
id = coder_app.claude_code_web.id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,10 @@ if [ -f "$log_file_path" ]; then
|
||||
mv "$log_file_path" "$log_file_path"".$(date +%s)"
|
||||
fi
|
||||
|
||||
# see the remove-last-session-id.js script for details
|
||||
# see the remove-last-session-id.sh script for details
|
||||
# about why we need it
|
||||
# avoid exiting if the script fails
|
||||
node "$scripts_dir/remove-last-session-id.js" "$(pwd)" || true
|
||||
bash "$scripts_dir/remove-last-session-id.sh" "$(pwd)" 2>/dev/null || true
|
||||
|
||||
# we'll be manually handling errors from this point on
|
||||
set +o errexit
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// If lastSessionId is present in .claude.json, claude --continue will start a
|
||||
// conversation starting from that session. The problem is that lastSessionId
|
||||
// doesn't always point to the last session. The field is updated by claude only
|
||||
// at the point of normal CLI exit. If Claude exits with an error, or if the user
|
||||
// restarts the Coder workspace, lastSessionId will be stale, and claude --continue
|
||||
// will start from an old session.
|
||||
//
|
||||
// If lastSessionId is missing, claude seems to accurately figure out where to
|
||||
// start using the conversation history - even if the CLI previously exited with
|
||||
// an error.
|
||||
//
|
||||
// This script removes the lastSessionId field from .claude.json.
|
||||
const path = require("path")
|
||||
const fs = require("fs")
|
||||
|
||||
const workingDirArg = process.argv[2]
|
||||
if (!workingDirArg) {
|
||||
console.log("No working directory provided - it must be the first argument")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const workingDir = path.resolve(workingDirArg)
|
||||
console.log("workingDir", workingDir)
|
||||
|
||||
|
||||
const claudeJsonPath = path.join(process.env.HOME, ".claude.json")
|
||||
console.log(".claude.json path", claudeJsonPath)
|
||||
if (!fs.existsSync(claudeJsonPath)) {
|
||||
console.log("No .claude.json file found")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8"))
|
||||
if ("projects" in claudeJson && workingDir in claudeJson.projects && "lastSessionId" in claudeJson.projects[workingDir]) {
|
||||
delete claudeJson.projects[workingDir].lastSessionId
|
||||
fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2))
|
||||
console.log("Removed lastSessionId from .claude.json")
|
||||
} else {
|
||||
console.log("No lastSessionId found in .claude.json - nothing to do")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# If lastSessionId is present in .claude.json, claude --continue will start a
|
||||
# conversation starting from that session. The problem is that lastSessionId
|
||||
# doesn't always point to the last session. The field is updated by claude only
|
||||
# at the point of normal CLI exit. If Claude exits with an error, or if the user
|
||||
# restarts the Coder workspace, lastSessionId will be stale, and claude --continue
|
||||
# will start from an old session.
|
||||
#
|
||||
# If lastSessionId is missing, claude seems to accurately figure out where to
|
||||
# start using the conversation history - even if the CLI previously exited with
|
||||
# an error.
|
||||
#
|
||||
# This script removes the lastSessionId field from .claude.json.
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "No working directory provided - it must be the first argument"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get absolute path of working directory
|
||||
working_dir=$(realpath "$1")
|
||||
echo "workingDir $working_dir"
|
||||
|
||||
# Path to .claude.json
|
||||
claude_json_path="$HOME/.claude.json"
|
||||
echo ".claude.json path $claude_json_path"
|
||||
|
||||
# Check if .claude.json exists
|
||||
if [ ! -f "$claude_json_path" ]; then
|
||||
echo "No .claude.json file found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use jq to check if lastSessionId exists for the working directory and remove it
|
||||
|
||||
if jq -e ".projects[\"$working_dir\"].lastSessionId" "$claude_json_path" > /dev/null 2>&1; then
|
||||
# Remove lastSessionId and update the file
|
||||
jq "del(.projects[\"$working_dir\"].lastSessionId)" "$claude_json_path" > "${claude_json_path}.tmp" && mv "${claude_json_path}.tmp" "$claude_json_path"
|
||||
echo "Removed lastSessionId from .claude.json"
|
||||
else
|
||||
echo "No lastSessionId found in .claude.json - nothing to do"
|
||||
fi
|
||||
@@ -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.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "cursor" {
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -39,23 +39,29 @@ module "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.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
coder = {
|
||||
command = "coder"
|
||||
args = ["exp", "mcp", "server"]
|
||||
env = {
|
||||
CODER_MCP_APP_STATUS_SLUG = "cursor"
|
||||
CODER_MCP_AI_AGENTAPI_URL = "http://localhost:3284"
|
||||
}
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.0.1"
|
||||
version = "1.1.0"
|
||||
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.0.1"
|
||||
version = "1.1.0"
|
||||
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.0.1"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
display_name = "Zed Editor"
|
||||
order = 1
|
||||
@@ -57,8 +57,36 @@ module "zed" {
|
||||
module "zed" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/zed/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
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 Zed’s settings files documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||
|
||||
@@ -50,7 +50,14 @@ 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 {
|
||||
@@ -60,6 +67,30 @@ 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
|
||||
|
||||
@@ -14,11 +14,26 @@ Provision Devcontainers as [Coder workspaces](https://coder.com/docs/workspaces)
|
||||
|
||||
### Infrastructure
|
||||
|
||||
Coder must have access to a running Docker socket, and the `coder` user must be a member of the `docker` group:
|
||||
#### Running Coder inside Docker
|
||||
|
||||
```shell
|
||||
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
|
||||
# Add coder user to Docker group
|
||||
sudo usermod -aG docker coder
|
||||
sudo adduser coder docker
|
||||
|
||||
# Restart Coder server
|
||||
sudo systemctl restart coder
|
||||
|
||||
@@ -16,7 +16,22 @@ Provision Docker containers as [Coder workspaces](https://coder.com/docs/workspa
|
||||
|
||||
### Infrastructure
|
||||
|
||||
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
#### 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:
|
||||
|
||||
```sh
|
||||
# Add coder user to Docker group
|
||||
|
||||
@@ -8,6 +8,8 @@ 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
|
||||
|
||||

|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -8,6 +8,8 @@ tags: [vm, linux, gcp]
|
||||
|
||||
# Remote Development on Google Compute Engine (Linux)
|
||||
|
||||
Provision Google Compute Engine instances as Coder workspaces
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -8,6 +8,8 @@ tags: [vm-container, linux, gcp]
|
||||
|
||||
# Remote Development on Google Compute Engine (VM Container)
|
||||
|
||||
Provision Google Compute Engine instances as Coder workspaces.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -8,6 +8,8 @@ tags: [vm, windows, gcp]
|
||||
|
||||
# Remote Development on Google Compute Engine (Windows)
|
||||
|
||||
Provision Google Compute Engine instances as Coder workspaces
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -8,6 +8,8 @@ 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,27 +1,29 @@
|
||||
---
|
||||
display_name: "Claude Code AI Agent Template"
|
||||
description: The goal is to try the experimental ai agent integration with Claude CodeAI agent
|
||||
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
|
||||
# AI agent template for a workspace in a container on a Docker host
|
||||
|
||||
### Docker image
|
||||
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
|
||||
## 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
|
||||
## Resources
|
||||
|
||||
[Coder docs on AI agents and tasks](https://coder.com/docs/ai-coder/tasks)
|
||||
|
||||
|
||||
+6
-10
@@ -103,8 +103,7 @@ add_json_error() {
|
||||
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}]')
|
||||
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() {
|
||||
@@ -112,8 +111,7 @@ add_json_warning() {
|
||||
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}]')
|
||||
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() {
|
||||
@@ -125,9 +123,7 @@ add_json_module() {
|
||||
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}]')
|
||||
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() {
|
||||
@@ -235,11 +231,11 @@ extract_version_from_readme() {
|
||||
}
|
||||
|
||||
local version_line
|
||||
version_line=$(grep -E "source\s*=\s*\"registry\.coder\.com/${namespace}/${module_name}" "$readme_path" | head -1 || echo "")
|
||||
version_line=$(grep -E "source[[:space:]]*=[[:space:]]*\"registry\.coder\.com/${namespace}/${module_name}" "$readme_path" | head -1 || echo "")
|
||||
|
||||
if [ -n "$version_line" ]; then
|
||||
local version
|
||||
version=$(echo "$version_line" | sed -n 's/.*version\s*=\s*"\([^"]*\)".*/\1/p')
|
||||
version=$(echo "$version_line" | sed -n 's/.*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$version" ]; then
|
||||
log "DEBUG" "Found version '$version' from source line: $version_line"
|
||||
echo "$version"
|
||||
@@ -248,7 +244,7 @@ extract_version_from_readme() {
|
||||
fi
|
||||
|
||||
local fallback_version
|
||||
fallback_version=$(grep -E 'version\s*=\s*"[0-9]+\.[0-9]+\.[0-9]+"' "$readme_path" | head -1 | sed 's/.*version\s*=\s*"\([^"]*\)".*/\1/' || echo "")
|
||||
fallback_version=$(grep -E 'version[[:space:]]*=[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' "$readme_path" | head -1 | sed 's/.*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/' || echo "")
|
||||
|
||||
if [ -n "$fallback_version" ]; then
|
||||
log "DEBUG" "Found fallback version '$fallback_version'"
|
||||
|
||||
Reference in New Issue
Block a user