diff --git a/coderd/templatebuilder/catalog.go b/coderd/templatebuilder/catalog.go index e4aef22cb1..1091be572d 100644 --- a/coderd/templatebuilder/catalog.go +++ b/coderd/templatebuilder/catalog.go @@ -35,6 +35,7 @@ type ModuleManifest struct { Tags []string `json:"tags"` CompatibleOS []string `json:"compatible_os"` ConflictsWith []string `json:"conflicts_with"` + Namespace string `json:"namespace"` PinnedVersion string `json:"pinned_version"` Variables []ModuleVariable `json:"variables"` } diff --git a/coderd/templatebuilder/modules/antigravity/antigravity.tf.tmpl b/coderd/templatebuilder/modules/antigravity/antigravity.tf.tmpl new file mode 100644 index 0000000000..9161d83564 --- /dev/null +++ b/coderd/templatebuilder/modules/antigravity/antigravity.tf.tmpl @@ -0,0 +1,9 @@ +module "antigravity" { + count = data.coder_workspace.me.start_count + source = "{{ .RegistryBase }}/coder/antigravity/coder" + version = "{{ .PinnedVersion }}" + agent_id = coder_agent.{{ .AgentResourceName }}.id + folder = {{ .Variables.folder }} + mcp = {{ .Variables.mcp }} + open_recent = {{ .Variables.open_recent }} +} diff --git a/coderd/templatebuilder/modules/antigravity/module.json b/coderd/templatebuilder/modules/antigravity/module.json new file mode 100644 index 0000000000..421449cab4 --- /dev/null +++ b/coderd/templatebuilder/modules/antigravity/module.json @@ -0,0 +1,56 @@ +{ + "id": "antigravity", + "display_name": "Antigravity", + "description": "Add a one-click button to launch Google Antigravity", + "icon": "/icon/antigravity.svg", + "category": "AI Agent", + "tags": [ + "ide", + "antigravity", + "ai", + "google" + ], + "compatible_os": [ + "linux" + ], + "conflicts_with": [], + "namespace": "coder", + "pinned_version": "1.0.1", + "variables": [ + { + "name": "agent_id", + "type": "string", + "description": "The ID of a Coder agent.", + "required": false, + "sensitive": false, + "computed": true + }, + { + "name": "folder", + "type": "string", + "description": "The folder to open in Antigravity IDE.", + "default": "", + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "mcp", + "type": "string", + "description": "JSON-encoded string to configure MCP servers for Antigravity. When set, writes ~/.gemini/antigravity/mcp_config.json.", + "default": "", + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "open_recent", + "type": "bool", + "description": "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open.", + "default": false, + "required": false, + "sensitive": false, + "computed": false + } + ] +} diff --git a/coderd/templatebuilder/modules/codex/codex.tf.tmpl b/coderd/templatebuilder/modules/codex/codex.tf.tmpl new file mode 100644 index 0000000000..03f59c1630 --- /dev/null +++ b/coderd/templatebuilder/modules/codex/codex.tf.tmpl @@ -0,0 +1,24 @@ + +variable "openai_api_key" { + description = "OpenAI API key for Codex CLI." + type = string + sensitive = true + default = "" +} +module "codex" { + count = data.coder_workspace.me.start_count + source = "{{ .RegistryBase }}/coder-labs/codex/coder" + version = "{{ .PinnedVersion }}" + agent_id = coder_agent.{{ .AgentResourceName }}.id + base_config_toml = {{ .Variables.base_config_toml }} + codex_version = {{ .Variables.codex_version }} + enable_ai_gateway = {{ .Variables.enable_ai_gateway }} + icon = {{ .Variables.icon }} + install_codex = {{ .Variables.install_codex }} + mcp = {{ .Variables.mcp }} + model_reasoning_effort = {{ .Variables.model_reasoning_effort }} + openai_api_key = var.openai_api_key + post_install_script = {{ .Variables.post_install_script }} + pre_install_script = {{ .Variables.pre_install_script }} + workdir = {{ .Variables.workdir }} +} diff --git a/coderd/templatebuilder/modules/codex/module.json b/coderd/templatebuilder/modules/codex/module.json new file mode 100644 index 0000000000..85cd796a13 --- /dev/null +++ b/coderd/templatebuilder/modules/codex/module.json @@ -0,0 +1,126 @@ +{ + "id": "codex", + "display_name": "Codex CLI", + "description": "Install and configure the Codex CLI in your workspace.", + "icon": "/icon/openai.svg", + "category": "AI Agent", + "tags": [ + "agent", + "codex", + "ai", + "openai", + "ai-gateway" + ], + "compatible_os": [ + "linux" + ], + "conflicts_with": [], + "namespace": "coder-labs", + "pinned_version": "5.3.0", + "variables": [ + { + "name": "agent_id", + "type": "string", + "description": "The ID of a Coder agent.", + "required": false, + "sensitive": false, + "computed": true + }, + { + "name": "base_config_toml", + "type": "string", + "description": "Complete base TOML configuration for Codex (without mcp_servers section).\nWhen empty, the module generates a minimal default:\n\n preferred_auth_method = \"apikey\"\n # model_provider = \"aigateway\" (sets the default profile, when enable_ai_gateway = true)\n # model_reasoning_effort = \"\u003cvalue\u003e\" (sets the reasoning effort, when model_reasoning_effort is set)\n\n [projects.\"\u003cworkdir\u003e\"] (when workdir is set)\n trust_level = \"trusted\"\n\nWhen non-empty, the value is written verbatim as the base of config.toml;\nmcp and AI Gateway sections are still appended after it.\nNote: model_reasoning_effort and workdir trust are only applied in the\ndefault config. Include them in your custom config if needed.\n", + "default": "", + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "codex_version", + "type": "string", + "description": "The version of Codex to install.", + "default": "latest", + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "enable_ai_gateway", + "type": "bool", + "description": "Use AI Gateway for Codex. https://coder.com/docs/ai-coder/ai-gateway", + "default": false, + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "icon", + "type": "string", + "description": "The icon to use for the app.", + "default": "/icon/openai.svg", + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "install_codex", + "type": "bool", + "description": "Whether to install Codex.", + "default": true, + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "mcp", + "type": "string", + "description": "MCP server configurations in TOML format. When set, servers are appended to the Codex config.toml.", + "default": "", + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "model_reasoning_effort", + "type": "string", + "description": "The reasoning effort for the model. One of: none, minimal, low, medium, high, xhigh. See https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort", + "default": "", + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "openai_api_key", + "type": "string", + "description": "OpenAI API key for Codex CLI.", + "default": "", + "required": false, + "sensitive": true, + "computed": false + }, + { + "name": "post_install_script", + "type": "string", + "description": "Custom script to run after installing Codex.", + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "pre_install_script", + "type": "string", + "description": "Custom script to run before installing Codex.", + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "workdir", + "type": "string", + "description": "Optional project directory. When set, the module pre-creates it if missing and adds it as a trusted project in Codex config.toml.", + "required": false, + "sensitive": false, + "computed": false + } + ] +} diff --git a/coderd/templatebuilder/modules/kasmvnc/kasmvnc.tf.tmpl b/coderd/templatebuilder/modules/kasmvnc/kasmvnc.tf.tmpl new file mode 100644 index 0000000000..82917c7206 --- /dev/null +++ b/coderd/templatebuilder/modules/kasmvnc/kasmvnc.tf.tmpl @@ -0,0 +1,9 @@ +module "kasmvnc" { + count = data.coder_workspace.me.start_count + source = "{{ .RegistryBase }}/coder/kasmvnc/coder" + version = "{{ .PinnedVersion }}" + agent_id = coder_agent.{{ .AgentResourceName }}.id + desktop_environment = {{ .Variables.desktop_environment }} + kasm_version = {{ .Variables.kasm_version }} + port = {{ .Variables.port }} +} diff --git a/coderd/templatebuilder/modules/kasmvnc/module.json b/coderd/templatebuilder/modules/kasmvnc/module.json new file mode 100644 index 0000000000..6063c68630 --- /dev/null +++ b/coderd/templatebuilder/modules/kasmvnc/module.json @@ -0,0 +1,54 @@ +{ + "id": "kasmvnc", + "display_name": "KasmVNC", + "description": "A modern open source VNC server", + "icon": "/icon/kasmvnc.svg", + "category": "Utility", + "tags": [ + "vnc", + "desktop", + "kasmvnc" + ], + "compatible_os": [ + "linux" + ], + "conflicts_with": [], + "namespace": "coder", + "pinned_version": "1.3.0", + "variables": [ + { + "name": "agent_id", + "type": "string", + "description": "The ID of a Coder agent.", + "required": false, + "sensitive": false, + "computed": true + }, + { + "name": "desktop_environment", + "type": "string", + "description": "Specifies the desktop environment of the workspace. This should be pre-installed on the workspace.", + "required": true, + "sensitive": false, + "computed": false + }, + { + "name": "kasm_version", + "type": "string", + "description": "Version of KasmVNC to install.", + "default": "1.4.0", + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "port", + "type": "number", + "description": "The port to run KasmVNC on.", + "default": 6800, + "required": false, + "sensitive": false, + "computed": false + } + ] +} diff --git a/scripts/templatebuildermodulegen/main.go b/scripts/templatebuildermodulegen/main.go index d8ca735ad0..cb92c348b7 100644 --- a/scripts/templatebuildermodulegen/main.go +++ b/scripts/templatebuildermodulegen/main.go @@ -3,10 +3,13 @@ package main import ( "context" "flag" + "fmt" "log" "os" "path/filepath" - "sort" + "strings" + + "golang.org/x/xerrors" ) // ModuleConfig defines the builder catalog metadata that cannot be @@ -16,6 +19,9 @@ type ModuleConfig struct { CompatibleOS []string `json:"compatible_os"` ConflictsWith []string `json:"conflicts_with"` SkipVars []string `json:"skip_vars,omitempty"` + // Namespace is the registry namespace (e.g. "coder" or "coder-labs"). + // When empty, defaults to "coder". + Namespace string `json:"namespace,omitempty"` } // moduleConfigs defines the builder-specific metadata for each module. @@ -29,8 +35,10 @@ var moduleConfigs = map[string]ModuleConfig{ "zed": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, "kiro": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, "claude-code": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, + "codex": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}, Namespace: "coder-labs"}, "aider": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, "amazon-q": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, + "antigravity": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, "git-clone": {Category: "Source Control", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, "git-config": {Category: "Source Control", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, "git-commit-signing": {Category: "Source Control", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, @@ -38,6 +46,7 @@ var moduleConfigs = map[string]ModuleConfig{ "personalize": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, "filebrowser": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, "jupyterlab": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, + "kasmvnc": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}}, } func main() { @@ -50,14 +59,24 @@ func main() { os.Exit(1) } + args := flag.Args() + if len(args) == 0 { + log.Fatal("specify one or more registry module IDs to generate (e.g. coder/antigravity coder-labs/codex)") + } + refs, err := resolveModuleArgs(args) + if err != nil { + log.Fatal(err) + } + ctx := context.Background() - moduleIDs := sortedKeys(moduleConfigs) var failures int - for _, id := range moduleIDs { + for _, ref := range refs { + id := ref.slug cfg := moduleConfigs[id] - registryID := "coder/" + id - log.Printf("Generating %s...", id) + namespace := ref.namespace + registryID := fmt.Sprintf("%s/%s", namespace, id) + log.Printf("Generating %s...", registryID) regMod, err := fetchModule(ctx, *baseURL, registryID) if err != nil { @@ -66,7 +85,7 @@ func main() { continue } - version, err := fetchLatestVersion(ctx, *baseURL, "coder", id) + version, err := fetchLatestVersion(ctx, *baseURL, namespace, id) if err != nil { log.Printf(" WARNING: could not determine version: %v", err) version = "0.0.0" @@ -83,6 +102,7 @@ func main() { Tags: regMod.Tags, CompatibleOS: cfg.CompatibleOS, ConflictsWith: cfg.ConflictsWith, + Namespace: namespace, PinnedVersion: version, Variables: vars, } @@ -114,11 +134,36 @@ func main() { } } -func sortedKeys(m map[string]ModuleConfig) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) +// moduleRef is a resolved module reference with its registry namespace and slug. +type moduleRef struct { + namespace string + slug string +} + +// resolveModuleArgs parses CLI arguments in "namespace/slug" format and +// validates each against moduleConfigs. Returns an error if any argument +// is malformed, references an unknown module, or has a namespace that +// does not match the configured value. +func resolveModuleArgs(args []string) ([]moduleRef, error) { + refs := make([]moduleRef, 0, len(args)) + for _, arg := range args { + parts := strings.SplitN(arg, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, xerrors.Errorf("invalid module ID %q; expected namespace/slug (e.g. coder/antigravity)", arg) + } + namespace, slug := parts[0], parts[1] + cfg, ok := moduleConfigs[slug] + if !ok { + return nil, xerrors.Errorf("unknown module %q; add it to moduleConfigs first", slug) + } + expectedNS := cfg.Namespace + if expectedNS == "" { + expectedNS = "coder" + } + if namespace != expectedNS { + return nil, xerrors.Errorf("module %q namespace mismatch: got %q, moduleConfigs expects %q", slug, namespace, expectedNS) + } + refs = append(refs, moduleRef{namespace: namespace, slug: slug}) } - sort.Strings(keys) - return keys + return refs, nil } diff --git a/scripts/templatebuildermodulegen/main_test.go b/scripts/templatebuildermodulegen/main_test.go new file mode 100644 index 0000000000..978396e9fa --- /dev/null +++ b/scripts/templatebuildermodulegen/main_test.go @@ -0,0 +1,251 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveModuleArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want []moduleRef + wantErr string + }{ + { + name: "single default namespace", + args: []string{"coder/claude-code"}, + want: []moduleRef{{namespace: "coder", slug: "claude-code"}}, + }, + { + name: "custom namespace", + args: []string{"coder-labs/codex"}, + want: []moduleRef{{namespace: "coder-labs", slug: "codex"}}, + }, + { + name: "multiple modules", + args: []string{"coder/antigravity", "coder-labs/codex", "coder/kasmvnc"}, + want: []moduleRef{ + {namespace: "coder", slug: "antigravity"}, + {namespace: "coder-labs", slug: "codex"}, + {namespace: "coder", slug: "kasmvnc"}, + }, + }, + { + name: "missing slash", + args: []string{"noslash"}, + wantErr: `invalid module ID "noslash"`, + }, + { + name: "empty namespace", + args: []string{"/slug"}, + wantErr: `invalid module ID "/slug"`, + }, + { + name: "empty slug", + args: []string{"namespace/"}, + wantErr: `invalid module ID "namespace/"`, + }, + { + name: "unknown module", + args: []string{"coder/nonexistent"}, + wantErr: `unknown module "nonexistent"`, + }, + { + name: "namespace mismatch", + args: []string{"wrong-ns/codex"}, + wantErr: `namespace mismatch: got "wrong-ns", moduleConfigs expects "coder-labs"`, + }, + { + name: "error on first bad arg stops", + args: []string{"coder/claude-code", "bad"}, + wantErr: `invalid module ID "bad"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := resolveModuleArgs(tt.args) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNormalizeIcon(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want string + }{ + {"/module/code.svg", "/icon/code.svg"}, + {"/module/nested/path.svg", "/icon/nested/path.svg"}, + {"/icon/already.svg", "/icon/already.svg"}, + {"/other/path.svg", "/other/path.svg"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, normalizeIcon(tt.input)) + }) + } +} + +func TestLatestVersion(t *testing.T) { + t.Parallel() + + entry := func(v string) struct { + Version string `json:"version"` + } { + return struct { + Version string `json:"version"` + }{Version: v} + } + + tests := []struct { + name string + entries []struct { + Version string `json:"version"` + } + want string + wantErr string + }{ + { + name: "single version", + entries: []struct { + Version string `json:"version"` + }{entry("1.0.0")}, + want: "1.0.0", + }, + { + name: "picks highest", + entries: []struct { + Version string `json:"version"` + }{entry("1.0.0"), entry("2.1.0"), entry("1.5.3")}, + want: "2.1.0", + }, + { + name: "handles v prefix", + entries: []struct { + Version string `json:"version"` + }{entry("v1.0.0"), entry("2.0.0")}, + want: "2.0.0", + }, + { + name: "skips invalid versions", + entries: []struct { + Version string `json:"version"` + }{entry("not-semver"), entry("1.2.3")}, + want: "1.2.3", + }, + { + name: "empty list", + entries: nil, + wantErr: "no valid semver", + }, + { + name: "all invalid", + entries: []struct { + Version string `json:"version"` + }{entry("bad"), entry("also-bad")}, + wantErr: "no valid semver", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := latestVersion(tt.entries) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestConvertVariables(t *testing.T) { + t.Parallel() + + t.Run("filters skipped and complex types", func(t *testing.T) { + t.Parallel() + vars := []registryVariable{ + {Name: "agent_id", Type: "string", Required: true}, + {Name: "order", Type: "number"}, + {Name: "complex_var", Type: "list(string)"}, + {Name: "user_name", Type: "string", Description: "The user name", Default: "default"}, + {Name: "count", Type: "number", Required: true}, + {Name: "enabled", Type: "bool", Default: true}, + {Name: "custom_skip", Type: "string"}, + } + + result := convertVariables(vars, []string{"custom_skip"}) + + require.Len(t, result, 4) + + // agent_id should be computed and not required + assert.Equal(t, "agent_id", result[0].Name) + assert.True(t, result[0].Computed) + assert.False(t, result[0].Required) + + // user_name should have default + assert.Equal(t, "user_name", result[1].Name) + assert.False(t, result[1].Computed) + assert.False(t, result[1].Required) + assert.Equal(t, json.RawMessage(`"default"`), result[1].Default) + + // count should be required + assert.Equal(t, "count", result[2].Name) + assert.True(t, result[2].Required) + + // enabled should have bool default + assert.Equal(t, "enabled", result[3].Name) + assert.Equal(t, json.RawMessage(`true`), result[3].Default) + }) + + t.Run("sensitive variable", func(t *testing.T) { + t.Parallel() + vars := []registryVariable{ + {Name: "api_key", Type: "string", Sensitive: true}, + } + result := convertVariables(vars, nil) + require.Len(t, result, 1) + assert.True(t, result[0].Sensitive) + }) + + t.Run("empty input", func(t *testing.T) { + t.Parallel() + result := convertVariables(nil, nil) + assert.Nil(t, result) + }) +} + +func TestModuleConfigsConsistency(t *testing.T) { + t.Parallel() + + for slug, cfg := range moduleConfigs { + t.Run(slug, func(t *testing.T) { + t.Parallel() + assert.NotEmpty(t, cfg.Category, "module %q has empty category", slug) + assert.NotEmpty(t, cfg.CompatibleOS, "module %q has empty compatible_os", slug) + assert.NotNil(t, cfg.ConflictsWith, "module %q has nil conflicts_with (use empty slice)", slug) + }) + } +} diff --git a/scripts/templatebuildermodulegen/types.go b/scripts/templatebuildermodulegen/types.go index 702409291b..9fbb5996d0 100644 --- a/scripts/templatebuildermodulegen/types.go +++ b/scripts/templatebuildermodulegen/types.go @@ -12,6 +12,7 @@ type ModuleManifest struct { Tags []string `json:"tags"` CompatibleOS []string `json:"compatible_os"` ConflictsWith []string `json:"conflicts_with"` + Namespace string `json:"namespace"` PinnedVersion string `json:"pinned_version"` Variables []ModuleVariable `json:"variables"` } diff --git a/scripts/templatebuildermodulegen/write.go b/scripts/templatebuildermodulegen/write.go index 2638e674db..3d4fd1485b 100644 --- a/scripts/templatebuildermodulegen/write.go +++ b/scripts/templatebuildermodulegen/write.go @@ -32,7 +32,7 @@ variable "{{ .Name }}" { {{ end -}} module "{{ .ID }}" { count = data.coder_workspace.me.start_count - source = "{{"{{"}} .RegistryBase {{"}}"}}/coder/{{ .ID }}/coder" + source = "{{"{{"}} .RegistryBase {{"}}"}}/{{ .Namespace }}/{{ .ID }}/coder" version = "{{"{{"}} .PinnedVersion {{"}}"}}" agent_id = coder_agent.{{"{{"}} .AgentResourceName {{"}}"}}.id {{- range .NonComputedVars }} @@ -47,6 +47,7 @@ module "{{ .ID }}" { type tfTmplData struct { ID string + Namespace string SensitiveVars []ModuleVariable NonComputedVars []ModuleVariable } @@ -66,6 +67,7 @@ func writeTFTmpl(path string, m ModuleManifest) error { data := tfTmplData{ ID: m.ID, + Namespace: m.Namespace, SensitiveVars: sensitiveVars, NonComputedVars: nonComputedVars, } diff --git a/site/src/pages/TemplateBuilder/ModuleSelectStep.tsx b/site/src/pages/TemplateBuilder/ModuleSelectStep.tsx index ce7954020e..d7abb8cf29 100644 --- a/site/src/pages/TemplateBuilder/ModuleSelectStep.tsx +++ b/site/src/pages/TemplateBuilder/ModuleSelectStep.tsx @@ -74,21 +74,21 @@ const ConflictWarning: FC = ({ moduleA, moduleB }) => { // in this order. Unlisted modules appear after, in their original order. const MODULE_PRIORITY: readonly string[] = [ "claude-code", - // "codex", + "codex", "cursor", "vscode-desktop", "code-server", "jetbrains", "vscode-web", "zed", - // "antigravity", + "antigravity", "windsurf", "git-clone", "dotfiles", "git-config", "personalize", "filebrowser", - // "kasmvnc", + "kasmvnc", ]; function sortByPriority(