diff --git a/cmd/readmevalidation/codermodules.go b/cmd/readmevalidation/codermodules.go new file mode 100644 index 00000000..4c9d854d --- /dev/null +++ b/cmd/readmevalidation/codermodules.go @@ -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 +} diff --git a/cmd/readmevalidation/coderresources_test.go b/cmd/readmevalidation/codermodules_test.go similarity index 86% rename from cmd/readmevalidation/coderresources_test.go rename to cmd/readmevalidation/codermodules_test.go index 71ec75f4..194a861e 100644 --- a/cmd/readmevalidation/coderresources_test.go +++ b/cmd/readmevalidation/codermodules_test.go @@ -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) } diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 7cf9259a..f7237a36 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -1,8 +1,6 @@ package main import ( - "bufio" - "context" "errors" "net/url" "os" @@ -89,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, "/") { @@ -120,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) @@ -133,108 +131,27 @@ func validateCoderResourceTags(tags []string) error { return nil } -func validateCoderResourceReadmeBody(body string) []error { - 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 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")) +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)} } - 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 := validateCoderResourceDisplayName(fm.DisplayName); err != nil { + errs = append(errs, addFilePathToError(filePath, err)) } - if err := validateCoderResourceDescription(rm.frontmatter.Description); err != nil { - errs = append(errs, addFilePathToError(rm.filePath, err)) + if err := validateCoderResourceDescription(fm.Description); err != nil { + errs = append(errs, addFilePathToError(filePath, err)) } - if err := validateCoderResourceTags(rm.frontmatter.Tags); err != nil { - errs = append(errs, addFilePathToError(rm.filePath, err)) + if err := validateCoderResourceTags(fm.Tags); err != nil { + errs = append(errs, addFilePathToError(filePath, err)) } - for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) { - errs = append(errs, addFilePathToError(rm.filePath, err)) + for _, err := range validateCoderResourceIconURL(fm.IconURL) { + errs = append(errs, addFilePathToError(filePath, err)) } - for _, err := range validateSupportedOperatingSystems(rm.frontmatter.OperatingSystems) { - errs = append(errs, addFilePathToError(rm.filePath, err)) + for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) { + errs = append(errs, addFilePathToError(filePath, err)) } return errs @@ -248,7 +165,7 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead keyErrs := validateFrontmatterYamlKeys(fm, supportedCoderResourceStructKeys) if len(keyErrs) != 0 { - remapped := []error{} + var remapped []error for _, e := range keyErrs { remapped = append(remapped, addFilePathToError(rm.filePath, e)) } @@ -268,7 +185,11 @@ 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 { @@ -287,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 @@ -359,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 -} diff --git a/cmd/readmevalidation/codertemplates.go b/cmd/readmevalidation/codertemplates.go new file mode 100644 index 00000000..df0c6c0b --- /dev/null +++ b/cmd/readmevalidation/codertemplates.go @@ -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 +} diff --git a/cmd/readmevalidation/contributors.go b/cmd/readmevalidation/contributors.go index c2e95e8a..8a9b195b 100644 --- a/cmd/readmevalidation/contributors.go +++ b/cmd/readmevalidation/contributors.go @@ -79,7 +79,7 @@ func validateContributorSupportEmail(email *string) []error { return nil } - errs := []error{} + var errs []error username, server, ok := strings.Cut(*email, "@") if !ok { @@ -140,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)) @@ -166,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)) @@ -202,7 +202,7 @@ func parseContributorProfile(rm readme) (contributorProfileReadme, []error) { keyErrs := validateFrontmatterYamlKeys(fm, supportedContributorProfileStructKeys) if len(keyErrs) != 0 { - remapped := []error{} + var remapped []error for _, e := range keyErrs { remapped = append(remapped, addFilePathToError(rm.filePath, e)) } @@ -223,7 +223,7 @@ 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, errs := parseContributorProfile(rm) if len(errs) != 0 { @@ -244,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...) @@ -267,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() { diff --git a/cmd/readmevalidation/main.go b/cmd/readmevalidation/main.go index cce66df9..49bfaf83 100644 --- a/cmd/readmevalidation/main.go +++ b/cmd/readmevalidation/main.go @@ -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) } diff --git a/registry/coder/templates/gcp-devcontainer/README.md b/registry/coder/templates/gcp-devcontainer/README.md index 329dad97..f1963294 100644 --- a/registry/coder/templates/gcp-devcontainer/README.md +++ b/registry/coder/templates/gcp-devcontainer/README.md @@ -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 + ![Architecture Diagram](../../.images/gcp-devcontainer-architecture.svg) ## Prerequisites diff --git a/registry/coder/templates/gcp-linux/README.md b/registry/coder/templates/gcp-linux/README.md index 04eb3ad7..4c57900f 100644 --- a/registry/coder/templates/gcp-linux/README.md +++ b/registry/coder/templates/gcp-linux/README.md @@ -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 diff --git a/registry/coder/templates/gcp-vm-container/README.md b/registry/coder/templates/gcp-vm-container/README.md index ea991bcb..5023f32a 100644 --- a/registry/coder/templates/gcp-vm-container/README.md +++ b/registry/coder/templates/gcp-vm-container/README.md @@ -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 diff --git a/registry/coder/templates/gcp-windows/README.md b/registry/coder/templates/gcp-windows/README.md index a206745d..e7e8d29a 100644 --- a/registry/coder/templates/gcp-windows/README.md +++ b/registry/coder/templates/gcp-windows/README.md @@ -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 diff --git a/registry/coder/templates/kubernetes-envbox/README.md b/registry/coder/templates/kubernetes-envbox/README.md index 4e440086..eb26745c 100644 --- a/registry/coder/templates/kubernetes-envbox/README.md +++ b/registry/coder/templates/kubernetes-envbox/README.md @@ -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. diff --git a/registry/sharkymark/templates/docker-claude/README.md b/registry/sharkymark/templates/docker-claude/README.md index d6829362..e0eca3af 100644 --- a/registry/sharkymark/templates/docker-claude/README.md +++ b/registry/sharkymark/templates/docker-claude/README.md @@ -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)