diff --git a/modules/code/insight_test.go b/modules/code/insight_test.go index 27ff61e5..a3eb438d 100644 --- a/modules/code/insight_test.go +++ b/modules/code/insight_test.go @@ -45,6 +45,7 @@ func (noopResolver) FetchItems(*cmdctx.Ctx, *spec.EndpointSpec, cmdctx.PagingFla return nil, nil } func (noopResolver) GetModuleMetas() []spec.ModuleMeta { return nil } +func (noopResolver) GetHiddenModule(string) *spec.ModuleMeta { return nil } func (noopResolver) GetSpecsForModule(string) []*spec.CommandSpec { return nil } func (noopResolver) GetAllSpecs() []*spec.CommandSpec { return nil } func (noopResolver) GetVerbInfos() []spec.VerbInfo { return nil } diff --git a/modules/core/mgmt/install.go b/modules/core/mgmt/install.go index 18775798..83e15ed6 100644 --- a/modules/core/mgmt/install.go +++ b/modules/core/mgmt/install.go @@ -16,12 +16,14 @@ import ( "path/filepath" "regexp" "runtime" + "slices" "strings" "time" "golang.org/x/mod/semver" "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/config" "github.com/harness/cli/pkg/hbase" "github.com/harness/cli/pkg/hlog" "github.com/harness/cli/pkg/release" @@ -277,7 +279,8 @@ func runInstalledBinary(binPath string, args ...string) error { return cmd.Run() } -// InstallModuleHandler installs a module that ships as a plugin. "module" is the +// InstallModuleHandler installs a module that ships as a plugin, or enables a +// module_type: hidden module that's already compiled in. "module" is the // feature-area axis and "plugin" is the deployment-type axis; a module that // isn't compiled in is installed exactly like any other plugin, so this hands // off to the one install path rather than duplicating it. @@ -286,17 +289,46 @@ func InstallModuleHandler(ctx *cmdctx.Ctx) error { if moduleName == "" { return fmt.Errorf("module name is required (supported: %s)", registryNames()) } + force := cmdctx.GetBool(ctx.FlagValues, "force") + check := cmdctx.GetBool(ctx.FlagValues, "check") + if m := ctx.Resolver.GetHiddenModule(moduleName); m != nil { + return installHiddenModule(m.Name, check, force) + } if _, ok := pluginRegistry[moduleName]; !ok { return fmt.Errorf("unknown module %q — supported: %s", moduleName, registryNames()) } version := cmdctx.GetString(ctx.FlagValues, "version") - force := cmdctx.GetBool(ctx.FlagValues, "force") - check := cmdctx.GetBool(ctx.FlagValues, "check") githubToken := cmdctx.GetString(ctx.FlagValues, "github-token") allowDrafts := cmdctx.GetBool(ctx.FlagValues, "allow-drafts") return installRegistryPlugin(moduleName, version, githubToken, allowDrafts, force, check) } +// installHiddenModule enables a module_type: hidden embedded module by +// persisting its name into Config.EnabledModules — no network, no binary +// download. Enabling is idempotent unless force is set. +func installHiddenModule(name string, check, force bool) error { + cfg, err := config.LoadConfig() + if err != nil { + return err + } + if slices.Contains(cfg.EnabledModules, name) && !force { + fmt.Printf("module %q is already enabled\n", name) + return nil + } + if check { + fmt.Printf("would enable module %q\n", name) + return nil + } + if !slices.Contains(cfg.EnabledModules, name) { + cfg.EnabledModules = append(cfg.EnabledModules, name) + } + if err := config.SaveConfig(cfg); err != nil { + return err + } + fmt.Printf("module %q enabled\n", name) + return nil +} + func detectPlatform() (string, error) { var os_, arch string switch runtime.GOOS { diff --git a/modules/core/mgmt/install_plugin.go b/modules/core/mgmt/install_plugin.go index d18758c5..fa657af1 100644 --- a/modules/core/mgmt/install_plugin.go +++ b/modules/core/mgmt/install_plugin.go @@ -77,7 +77,7 @@ func UninstalledRegistryPlugins(seen map[string]bool) []spec.ModuleMeta { sort.Strings(names) metas := make([]spec.ModuleMeta, 0, len(names)) for _, name := range names { - metas = append(metas, spec.ModuleMeta{Name: name, Type: "plugin", Desc: pluginRegistry[name].Desc}) + metas = append(metas, spec.ModuleMeta{Name: name, Type: spec.ModuleTypePlugin, Desc: pluginRegistry[name].Desc}) } return metas } diff --git a/modules/core/mgmt/modules.go b/modules/core/mgmt/modules.go index c35202e2..539aa791 100644 --- a/modules/core/mgmt/modules.go +++ b/modules/core/mgmt/modules.go @@ -311,6 +311,11 @@ func ListModulesFetchFn(ctx *cmdctx.Ctx, _ *spec.EndpointSpec, _, _ int, _ any) // install state. installed := "-" version := "-" + if m.Type == spec.ModuleTypeHidden { + // A hidden module only ever appears here once enabled — a disabled + // one is absent from GetModuleMetas() entirely. + installed = "yes" + } if m.BinaryPath != "" { // Plugin: trust the spec's provenance, which install captured from // --identity. Listing never execs a binary to read a version. diff --git a/modules/gitops/gitops_test.go b/modules/gitops/gitops_test.go index 155f6efa..ff3b9f4f 100644 --- a/modules/gitops/gitops_test.go +++ b/modules/gitops/gitops_test.go @@ -43,6 +43,7 @@ func (noopResolver) FetchItems(*cmdctx.Ctx, *spec.EndpointSpec, cmdctx.PagingFla return nil, nil } func (noopResolver) GetModuleMetas() []spec.ModuleMeta { return nil } +func (noopResolver) GetHiddenModule(string) *spec.ModuleMeta { return nil } func (noopResolver) GetSpecsForModule(string) []*spec.CommandSpec { return nil } func (noopResolver) GetAllSpecs() []*spec.CommandSpec { return nil } func (noopResolver) GetVerbInfos() []spec.VerbInfo { return nil } diff --git a/pkg/cmdctx/cmdctx.go b/pkg/cmdctx/cmdctx.go index 5ee53544..8be3bcd4 100644 --- a/pkg/cmdctx/cmdctx.go +++ b/pkg/cmdctx/cmdctx.go @@ -145,6 +145,9 @@ type Resolver interface { FetchItems(ctx *Ctx, ep *spec.EndpointSpec, pf PagingFlags) ([]any, error) // GetModuleMetas returns metadata for all loaded modules in load order. GetModuleMetas() []spec.ModuleMeta + // GetHiddenModule returns the recorded stub for a module_type: hidden + // module by name (recorded regardless of enablement), or nil. + GetHiddenModule(name string) *spec.ModuleMeta // GetSpecsForModule returns all registered CommandSpecs belonging to the given module. GetSpecsForModule(module string) []*spec.CommandSpec // GetAllSpecs returns every registered CommandSpec across all modules. diff --git a/pkg/config/config.go b/pkg/config/config.go index fea34bad..173fe06e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -48,6 +48,7 @@ type Config struct { Profiles map[string]*Profile `yaml:"profiles"` DisableTelemetry bool `yaml:"disable_telemetry,omitempty"` TelemetryID string `yaml:"telemetry_id,omitempty"` + EnabledModules []string `yaml:"enabled_modules,omitempty"` // hidden modules enabled via `install module ` } func LoadConfig() (*Config, error) { diff --git a/pkg/hbase/hbase.go b/pkg/hbase/hbase.go index b78abd48..553a3fad 100644 --- a/pkg/hbase/hbase.go +++ b/pkg/hbase/hbase.go @@ -71,6 +71,18 @@ const ( // EnvCLIHome overrides the harness home directory (default ~/.harness). EnvCLIHome = "HARNESS_CLI_HOME" + // EnvEnableBetaModules is a tri-state env var consulted when resolving + // whether a module_type: hidden module is enabled. "1" enables every + // hidden module; "0" forces off the harness.io-employee auto-detect + // (falling through to the config file); unset defers to the auto-detect. + EnvEnableBetaModules = "HARNESS_ENABLE_BETA_MODULES" + + // EnvEnabledModules, when set at all (including to ""), is a + // comma-separated allowlist of hidden module names to enable — a hard + // override of both EnvEnableBetaModules and the config file for this + // invocation. + EnvEnabledModules = "HARNESS_CLI_ENABLED_MODULES" + // Env var names for env-var auth mode. EnvAPIKey = "HARNESS_API_KEY" EnvAPIJWT = "HARNESS_API_JWT" diff --git a/pkg/registry/checks.go b/pkg/registry/checks.go index 4a580ed0..bf6cd1bd 100644 --- a/pkg/registry/checks.go +++ b/pkg/registry/checks.go @@ -23,12 +23,41 @@ func (r *Registry) CheckFunctions() error { for noun, nd := range r.nouns { errs = append(errs, r.checkUICommands(noun, nd)...) } + errs = append(errs, r.checkModuleTypes()...) if len(errs) > 0 { return errors.New("registry errors:\n " + strings.Join(errs, "\n ")) } return nil } +// validModuleTypes are the only accepted values for a spec file's top-level +// module_type field. +var validModuleTypes = map[string]bool{ + spec.ModuleTypeBuiltin: true, + spec.ModuleTypePlugin: true, + spec.ModuleTypeHidden: true, +} + +// checkModuleTypes validates that every loaded module — including module_type: +// hidden modules recorded regardless of enablement — declares one of the +// accepted module_type values. +func (r *Registry) checkModuleTypes() []string { + var errs []string + check := func(m spec.ModuleMeta) { + if !validModuleTypes[m.Type] { + errs = append(errs, fmt.Sprintf("module %q: invalid module_type %q (must be %q, %q, or %q)", + m.Name, m.Type, spec.ModuleTypeBuiltin, spec.ModuleTypePlugin, spec.ModuleTypeHidden)) + } + } + for _, m := range r.moduleMetas { + check(m) + } + for _, m := range r.hiddenModules { + check(m) + } + return errs +} + // reservedUIKeys are hardcoded to scroll/quit/print handling in the detail // overlay's key switch (see uitableview.go) and never reach ui_commands // dispatch, so a spec binding one of them would silently never fire. diff --git a/pkg/registry/fields_test.go b/pkg/registry/fields_test.go index c9fc2bdb..37570b53 100644 --- a/pkg/registry/fields_test.go +++ b/pkg/registry/fields_test.go @@ -90,6 +90,7 @@ func (tr *testResolver) GetNoun(noun string) *spec.NounDef { r func (tr *testResolver) ResolveNounAlias(alias string) string { return "" } func (tr *testResolver) GetVerbInfos() []spec.VerbInfo { return nil } func (tr *testResolver) GetModuleMetas() []spec.ModuleMeta { return nil } +func (tr *testResolver) GetHiddenModule(string) *spec.ModuleMeta { return nil } func (tr *testResolver) GetAllSpecs() []*spec.CommandSpec { return nil } func (tr *testResolver) GetSpecsForModule(module string) []*spec.CommandSpec { return nil } func (tr *testResolver) ResolveTextFormatter(id string) cmdctx.TextFormatterFn { diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 1fe7c50b..03b820bd 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -55,6 +55,7 @@ type Registry struct { nounAliases map[string]string // alias name → canonical noun name pluginOwnedNouns map[string]string // noun (or alias) → owning plugin module moduleMetas []spec.ModuleMeta + hiddenModules []spec.ModuleMeta // module_type: hidden modules, recorded regardless of enablement workflows map[string]WorkflowFn textFormatters map[string]cmdctx.TextFormatterFn bodyFns map[string]cmdctx.CreateBodyFn @@ -98,6 +99,25 @@ func (r *Registry) SetModuleMeta(m spec.ModuleMeta) { r.moduleMetas = append(r.moduleMetas, m) } +// RecordHiddenModule records a module declared module_type: hidden, regardless +// of whether it is currently enabled. This is the only place `install module` +// can recognize a hidden-but-disabled module by name — such a module is +// otherwise fully absent from GetModuleMetas. +func (r *Registry) RecordHiddenModule(m spec.ModuleMeta) { + r.hiddenModules = append(r.hiddenModules, m) +} + +// GetHiddenModule returns the recorded hidden-module stub for name, or nil if +// name isn't a known module_type: hidden module. +func (r *Registry) GetHiddenModule(name string) *spec.ModuleMeta { + for i := range r.hiddenModules { + if r.hiddenModules[i].Name == name { + return &r.hiddenModules[i] + } + } + return nil +} + // RecordPluginOwnedNouns records module as the owner of nouns (and their // aliases), for best-effort "plugin not installed" error messages. func (r *Registry) RecordPluginOwnedNouns(module string, nouns []spec.NounDef) { diff --git a/pkg/spec/aievals.spec.yaml b/pkg/spec/aievals.spec.yaml index 761b2d04..9c83af6b 100644 --- a/pkg/spec/aievals.spec.yaml +++ b/pkg/spec/aievals.spec.yaml @@ -1,7 +1,6 @@ spec_version: 1 -module_type: builtin +module_type: hidden module_desc: Harness AI Evals — datasets, evaluations, runs, metrics, metric sets, suites, and targets -harness_internal: true help_text: | ## AI Evals (aievals) diff --git a/pkg/spec/autonomous_work.spec.yaml b/pkg/spec/autonomous_work.spec.yaml new file mode 100644 index 00000000..e63b8207 --- /dev/null +++ b/pkg/spec/autonomous_work.spec.yaml @@ -0,0 +1,1045 @@ +spec_version: 1 +module_type: hidden +module_desc: "Development Harness (ADLC) — autonomous AI-driven software work: work items, work classes, budgets, teams, members, triggers, and agent dispatch" +help_text: | + ## Development Harness (autonomous_work) + + Development Harness is the execution plane for autonomous AI-driven software + work. A `work_item` progresses through a lifecycle + `plan → design → implement → review → merged` (terminal `failed`), governed + by a `work_class`, budgets, risk evaluators, teams/members, triggers, and + human gates. + + ### Domain Model + + - `work_item` — the core entity. List/get only; work items are created via + Slack or the agent-execution flow, not a public POST. Resume a blocked item + with `execute work_item:resume `; approve a gate with + `execute work_item:approve --decision approve`. + - `work_class` — the runtime model for a category of autonomous work. + - `budget` — cost/approval budgets; grant an increment with + `execute budget:grant `; see usage with `get budget:usage `. + - `team`, `member`, `member_template` — the AI team configuration. + - `software_component` — a component a team owns. + - `trigger`, `capability`, `risk_evaluator` — work-config definitions. + - `content_source_connector` — default content sources; set with + `execute content_source_connector:set_default`. + - `agent_execution` — launch an autonomous agent pipeline execution: + `execute agent_execution --org --project `. + + All resources are project-scoped. Pass `--org` and `--project` on every + command (or set them on the auth profile with `harness auth setscope`). + + ### Nouns + + {{nouns}} + +nouns: + - noun: work_item + short_desc: An autonomous software work unit (plan→design→implement→review→merged). + noun_aliases: [work_items] + url_path: /ng/account/{{auth.account}}/module/dh/orgs/{{auth.org}}/projects/{{auth.project}}/workitems/{{it.id}} + fields: + - id: id + expr: it.id + - id: title + expr: it.title + width_max: 50 + - id: state + expr: it.state + - id: work_class + expr: it.workClassRef.id + - id: trigger_type + expr: it.trigger.type + - id: jira_key + expr: it.trigger.jiraKey + - id: version + expr: it.version + align: right + - id: updated + expr: epochMs(it.updatedAt) + - id: created + expr: epochMs(it.createdAt) + + - noun: work_class + short_desc: A WorkClass definition — the runtime model for a category of autonomous work. + noun_aliases: [work_classes] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + width_max: 40 + - id: team + expr: it.teamId.id + - id: updated + expr: epochMs(it.updatedAt) + - id: created + expr: epochMs(it.createdAt) + + - noun: work_trigger + short_desc: A work trigger (manual / scheduled / webhook). + noun_aliases: [work_triggers] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + width_max: 40 + - id: updated + expr: epochMs(it.updatedAt) + - id: created + expr: epochMs(it.createdAt) + + - noun: capability + short_desc: A capability grant for work classes. + noun_aliases: [capabilities] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + width_max: 40 + - id: updated + expr: epochMs(it.updatedAt) + - id: created + expr: epochMs(it.createdAt) + + - noun: risk_evaluator + short_desc: A risk evaluator definition. + noun_aliases: [risk_evaluators] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + width_max: 40 + - id: updated + expr: epochMs(it.updatedAt) + - id: created + expr: epochMs(it.createdAt) + + - noun: budget + short_desc: A cost/approval budget for autonomous work. + noun_aliases: [budgets] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + width_max: 40 + - id: updated + expr: epochMs(it.updatedAt) + - id: created + expr: epochMs(it.createdAt) + + - noun: team + short_desc: A team configuration. + noun_aliases: [teams] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + width_max: 40 + - id: updated + expr: epochMs(it.updatedAt) + - id: created + expr: epochMs(it.createdAt) + + - noun: member + short_desc: An AI team member. + noun_aliases: [members] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + width_max: 40 + - id: updated + expr: epochMs(it.updatedAt) + - id: created + expr: epochMs(it.createdAt) + + - noun: member_template + short_desc: A reusable member template. + noun_aliases: [member_templates] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + width_max: 40 + - id: updated + expr: epochMs(it.updatedAt) + - id: created + expr: epochMs(it.createdAt) + + - noun: software_component + short_desc: A software component a team owns. + noun_aliases: [software_components] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + width_max: 40 + - id: updated + expr: epochMs(it.updatedAt) + - id: created + expr: epochMs(it.createdAt) + + - noun: content_source_connector + short_desc: Default content-source connectors. + noun_aliases: [content_source_connectors] + fields: + - id: source_type + expr: it.sourceType + - id: connector_ref + expr: it.connectorRef + - id: updated + expr: epochMs(it.updatedAt) + + - noun: agent_execution + short_desc: An autonomous agent pipeline execution launch result. + noun_aliases: [agent_executions] + fields: + - id: execution_id + expr: it.executionId + - id: status + expr: it.status + +commands: + # ── work_item ────────────────────────────────────────────────────────────────── + + - command: list work_item + verb: list + noun: work_item + short: List work items in a project + handler_type: endpoint + endpoint: + path: /adlc/api/workitems + items_expr: it.items + get_id_expr: it.id + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + offset: flags.page + limit: flags.limit + paging: + paging_strategy: flat_list + columns: [id, title, state, work_class, trigger_type, updated] + + - command: get work_item + verb: get + noun: work_item + short: Get a single work item by ID + handler_type: endpoint + endpoint: + path: /adlc/api/workitems/{{ctx.id}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + # ── work_item sub-resources ──────────────────────────────────────────────────── + + - command: get work_item:timeline + verb: get + noun: work_item + noun_variant: timeline + short: Get the timeline rail for a work item + handler_type: endpoint + endpoint: + path: /adlc/api/workitems/{{ctx.id}}/timeline + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: get work_item:budgets + verb: get + noun: work_item + noun_variant: budgets + short: Get budgets applying to a work item with current usage + handler_type: endpoint + endpoint: + path: /adlc/api/workitems/{{ctx.id}}/budgets + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: get work_item:phase + verb: get + noun: work_item + noun_variant: phase + id_parts: 2 + short: Get phase detail (workItemId/phaseId) + handler_type: endpoint + endpoint: + path: /adlc/api/workitems/{{ctx.idParts[0]}}/phases/{{ctx.idParts[1]}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: list work_item:phase_artifacts + verb: list + noun: work_item + noun_variant: phase_artifacts + id_parts: 2 + short: List artifacts for a phase of a work item (workItemId/phaseId) + handler_type: endpoint + endpoint: + path: /adlc/api/workitems/{{ctx.idParts[0]}}/phases/{{ctx.idParts[1]}}/artifacts + items_expr: it.items + get_id_expr: it.id + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + paging: + paging_strategy: flat_list + + - command: get work_item:artifact + verb: get + noun: work_item + noun_variant: artifact + id_parts: 3 + short: Get artifact content (workItemId/phaseId/artifactId) + handler_type: endpoint + endpoint: + path: /adlc/api/workitems/{{ctx.idParts[0]}}/phases/{{ctx.idParts[1]}}/artifacts/{{ctx.idParts[2]}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + # ── work_item lifecycle actions ───────────────────────────────────────────────── + + - command: execute work_item:resume + verb: execute + noun: work_item + noun_variant: resume + short: Resume a budget-blocked or gated work item + handler_type: endpoint + endpoint: + method: POST + path: /adlc/api/workitems/{{ctx.id}}/resume + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: execute work_item:approve + verb: execute + noun: work_item + noun_variant: approve + short: Approve or reject a pending gate (--decision approve|reject) + handler_type: endpoint + flags: + - name: decision + description: "Approval decision (e.g. approve, reject)" + required: true + - name: reason + description: Optional reason for the decision + endpoint: + method: POST + path: /adlc/api/workitems/{{ctx.id}}/approve + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + body_params: + decision: flags.decision + reason: 'flags.reason != "" ? flags.reason : nil' + item_expr: it + + # ── budget ──────────────────────────────────────────────────────────────────── + + - command: list budget + verb: list + noun: budget + short: List budgets in a project + handler_type: endpoint + endpoint: + path: /adlc/api/budgets + items_expr: it.items + get_id_expr: it.id + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + paging: + paging_strategy: flat_list + columns: [id, name, updated] + + - command: get budget + verb: get + noun: budget + short: Get a single budget by ID + handler_type: endpoint + endpoint: + path: /adlc/api/budgets/{{ctx.id}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: get budget:usage + verb: get + noun: budget + noun_variant: usage + short: Get current-period usage for a budget + handler_type: endpoint + endpoint: + path: /adlc/api/budgets/{{ctx.id}}/usage + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: execute budget:grant + verb: execute + noun: budget + noun_variant: grant + short: Apply one approval increment to a budget + handler_type: endpoint + endpoint: + method: POST + path: /adlc/api/budgets/{{ctx.id}}/grant + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + # ── work_class CRUD ──────────────────────────────────────────────────────────── + + - command: list work_class + verb: list + noun: work_class + short: List work classes in a project + handler_type: endpoint + endpoint: + path: /adlc/api/work-classes + items_expr: it.items + get_id_expr: it.id + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + offset: flags.page + limit: flags.limit + paging: + paging_strategy: flat_list + columns: [id, name, team, updated] + + - command: get work_class + verb: get + noun: work_class + short: Get a single work class by ID + handler_type: endpoint + endpoint: + path: /adlc/api/work-classes/{{ctx.id}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: create work_class + verb: create + noun: work_class + short: Create a work class from a YAML ask-body (--file workclass.yaml) + handler_type: endpoint + endpoint: + method: POST + file_body: required + path: /adlc/api/work-classes + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: update work_class + verb: update + noun: work_class + short: Update a work class from a YAML ask-body (--file workclass.yaml) + handler_type: endpoint + endpoint: + method: PUT + file_body: required + path: /adlc/api/work-classes/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: delete work_class + verb: delete + noun: work_class + short: Delete a work class + handler_type: endpoint + endpoint: + method: DELETE + path: /adlc/api/work-classes/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + # ── work_trigger CRUD ────────────────────────────────────────────────────────── + + - command: list work_trigger + verb: list + noun: work_trigger + short: List triggers in a project + handler_type: endpoint + endpoint: + path: /adlc/api/triggers + items_expr: it.items + get_id_expr: it.id + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + paging: + paging_strategy: flat_list + columns: [id, name, updated] + + - command: get work_trigger + verb: get + noun: work_trigger + short: Get a single trigger by ID + handler_type: endpoint + endpoint: + path: /adlc/api/triggers/{{ctx.id}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: create work_trigger + verb: create + noun: work_trigger + short: Create a trigger from a YAML ask-body (--file trigger.yaml) + handler_type: endpoint + endpoint: + method: POST + file_body: required + path: /adlc/api/triggers + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: update work_trigger + verb: update + noun: work_trigger + short: Update a trigger from a YAML ask-body (--file trigger.yaml) + handler_type: endpoint + endpoint: + method: PUT + file_body: required + path: /adlc/api/triggers/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: delete work_trigger + verb: delete + noun: work_trigger + short: Delete a trigger + handler_type: endpoint + endpoint: + method: DELETE + path: /adlc/api/triggers/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + # ── capability CRUD ──────────────────────────────────────────────────────────── + + - command: list capability + verb: list + noun: capability + short: List capabilities in a project + handler_type: endpoint + endpoint: + path: /adlc/api/capabilities + items_expr: it.items + get_id_expr: it.id + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + paging: + paging_strategy: flat_list + columns: [id, name, updated] + + - command: get capability + verb: get + noun: capability + short: Get a single capability by ID + handler_type: endpoint + endpoint: + path: /adlc/api/capabilities/{{ctx.id}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: create capability + verb: create + noun: capability + short: Create a capability from a YAML ask-body (--file capability.yaml) + handler_type: endpoint + endpoint: + method: POST + file_body: required + path: /adlc/api/capabilities + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: update capability + verb: update + noun: capability + short: Update a capability from a YAML ask-body (--file capability.yaml) + handler_type: endpoint + endpoint: + method: PUT + file_body: required + path: /adlc/api/capabilities/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: delete capability + verb: delete + noun: capability + short: Delete a capability + handler_type: endpoint + endpoint: + method: DELETE + path: /adlc/api/capabilities/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + # ── risk_evaluator CRUD ──────────────────────────────────────────────────────── + + - command: list risk_evaluator + verb: list + noun: risk_evaluator + short: List risk evaluators in a project + handler_type: endpoint + endpoint: + path: /adlc/api/risk-evaluators + items_expr: it.items + get_id_expr: it.id + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + paging: + paging_strategy: flat_list + columns: [id, name, updated] + + - command: get risk_evaluator + verb: get + noun: risk_evaluator + short: Get a single risk evaluator by ID + handler_type: endpoint + endpoint: + path: /adlc/api/risk-evaluators/{{ctx.id}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: create risk_evaluator + verb: create + noun: risk_evaluator + short: Create a risk evaluator from a YAML ask-body (--file risk-evaluator.yaml) + handler_type: endpoint + endpoint: + method: POST + file_body: required + path: /adlc/api/risk-evaluators + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: update risk_evaluator + verb: update + noun: risk_evaluator + short: Update a risk evaluator from a YAML ask-body (--file risk-evaluator.yaml) + handler_type: endpoint + endpoint: + method: PUT + file_body: required + path: /adlc/api/risk-evaluators/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: delete risk_evaluator + verb: delete + noun: risk_evaluator + short: Delete a risk evaluator + handler_type: endpoint + endpoint: + method: DELETE + path: /adlc/api/risk-evaluators/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + # ── team CRUD ────────────────────────────────────────────────────────────────── + + - command: list team + verb: list + noun: team + short: List teams in a project + handler_type: endpoint + endpoint: + path: /adlc/api/teams + items_expr: it.items + get_id_expr: it.id + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + paging: + paging_strategy: flat_list + columns: [id, name, updated] + + - command: get team + verb: get + noun: team + short: Get a single team by ID + handler_type: endpoint + endpoint: + path: /adlc/api/teams/{{ctx.id}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: create team + verb: create + noun: team + short: Create a team from a YAML ask-body (--file team.yaml) + handler_type: endpoint + endpoint: + method: POST + file_body: required + path: /adlc/api/teams + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: update team + verb: update + noun: team + short: Update a team from a YAML ask-body (--file team.yaml) + handler_type: endpoint + endpoint: + method: PUT + file_body: required + path: /adlc/api/teams/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: delete team + verb: delete + noun: team + short: Delete a team + handler_type: endpoint + endpoint: + method: DELETE + path: /adlc/api/teams/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + # ── member CRUD ───────────────────────────────────────────────────────────────── + + - command: list member + verb: list + noun: member + short: List members in a project + handler_type: endpoint + endpoint: + path: /adlc/api/members + items_expr: it.items + get_id_expr: it.id + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + paging: + paging_strategy: flat_list + columns: [id, name, updated] + + - command: get member + verb: get + noun: member + short: Get a single member by ID + handler_type: endpoint + endpoint: + path: /adlc/api/members/{{ctx.id}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: create member + verb: create + noun: member + short: Create a member from a YAML ask-body (--file member.yaml) + handler_type: endpoint + endpoint: + method: POST + file_body: required + path: /adlc/api/members + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: update member + verb: update + noun: member + short: Update a member from a YAML ask-body (--file member.yaml) + handler_type: endpoint + endpoint: + method: PUT + file_body: required + path: /adlc/api/members/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: delete member + verb: delete + noun: member + short: Delete a member + handler_type: endpoint + endpoint: + method: DELETE + path: /adlc/api/members/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + # ── member_template CRUD ─────────────────────────────────────────────────────── + + - command: list member_template + verb: list + noun: member_template + short: List member templates in a project + handler_type: endpoint + endpoint: + path: /adlc/api/member-templates + items_expr: it.items + get_id_expr: it.id + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + paging: + paging_strategy: flat_list + columns: [id, name, updated] + + - command: get member_template + verb: get + noun: member_template + short: Get a single member template by ID + handler_type: endpoint + endpoint: + path: /adlc/api/member-templates/{{ctx.id}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: create member_template + verb: create + noun: member_template + short: Create a member template from a YAML ask-body (--file member-template.yaml) + handler_type: endpoint + endpoint: + method: POST + file_body: required + path: /adlc/api/member-templates + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: update member_template + verb: update + noun: member_template + short: Update a member template from a YAML ask-body (--file member-template.yaml) + handler_type: endpoint + endpoint: + method: PUT + file_body: required + path: /adlc/api/member-templates/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: delete member_template + verb: delete + noun: member_template + short: Delete a member template + handler_type: endpoint + endpoint: + method: DELETE + path: /adlc/api/member-templates/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + # ── software_component CRUD ───────────────────────────────────────────────────── + + - command: list software_component + verb: list + noun: software_component + short: List software components in a project + handler_type: endpoint + endpoint: + path: /adlc/api/software-components + items_expr: it.items + get_id_expr: it.id + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + paging: + paging_strategy: flat_list + columns: [id, name, updated] + + - command: get software_component + verb: get + noun: software_component + short: Get a single software component by ID + handler_type: endpoint + endpoint: + path: /adlc/api/software-components/{{ctx.id}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: create software_component + verb: create + noun: software_component + short: Create a software component from a YAML ask-body (--file software-component.yaml) + handler_type: endpoint + endpoint: + method: POST + file_body: required + path: /adlc/api/software-components + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: update software_component + verb: update + noun: software_component + short: Update a software component from a YAML ask-body (--file software-component.yaml) + handler_type: endpoint + endpoint: + method: PUT + file_body: required + path: /adlc/api/software-components/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + - command: delete software_component + verb: delete + noun: software_component + short: Delete a software component + handler_type: endpoint + endpoint: + method: DELETE + path: /adlc/api/software-components/{{ctx.id}} + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + # ── content_source_connector ─────────────────────────────────────────────────── + + - command: list content_source_connector + verb: list + noun: content_source_connector + short: List content-source connectors in a project + handler_type: endpoint + endpoint: + path: /adlc/api/content-source-connectors + items_expr: it.items + get_id_expr: it.sourceType + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + paging: + paging_strategy: flat_list + columns: [source_type, connector_ref, updated] + + - command: get content_source_connector + verb: get + noun: content_source_connector + short: Get a single content-source connector by source type + handler_type: endpoint + endpoint: + path: /adlc/api/content-source-connectors/{{ctx.id}} + item_expr: it + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + + - command: execute content_source_connector:set_default + verb: execute + noun: content_source_connector + noun_variant: set_default + no_id: true + short: Set default content-source connectors from a YAML body (--file connectors.yaml) + handler_type: endpoint + endpoint: + method: PUT + file_body: required + path: /adlc/api/content-source-connectors + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + + # ── agent_execution ───────────────────────────────────────────────────────────── + + - command: execute agent_execution + verb: execute + noun: agent_execution + no_id: true + short: Launch an autonomous agent pipeline execution + handler_type: endpoint + endpoint: + method: POST + path: /adlc/api/agent/executions + query_params: + orgIdentifier: auth.org + projectIdentifier: auth.project + item_expr: it + columns: [execution_id, status] \ No newline at end of file diff --git a/pkg/spec/fme.spec.yaml b/pkg/spec/fme.spec.yaml index 4a19ff22..1e9856c2 100644 --- a/pkg/spec/fme.spec.yaml +++ b/pkg/spec/fme.spec.yaml @@ -1,7 +1,6 @@ spec_version: 1 -module_type: builtin +module_type: hidden module_desc: Harness FME — Feature flags and targeting definitions -harness_internal: true help_text: | ## Feature Management & Experimentation (fme) diff --git a/pkg/spec/spec.go b/pkg/spec/spec.go index 4846385b..b3924c72 100644 --- a/pkg/spec/spec.go +++ b/pkg/spec/spec.go @@ -173,10 +173,21 @@ type Flag struct { FlagResolveFn string `yaml:"flag_resolve_fn,omitempty"` // registered FlagResolveFn name; transforms the raw flag string value before CEL evaluation } +// Valid module_type values for a spec file's top-level module_type field. +const ( + ModuleTypeBuiltin = "builtin" // compiled into the binary, always enabled + ModuleTypePlugin = "plugin" // dispatches to a separately installed binary + // ModuleTypeHidden marks a module as not enabled by default: invisible in + // list/get module and no commands registered until something enables it. + // It replaces module_type: builtin (not module_type: plugin) — a hidden + // module is still compiled into the binary, just opt-in. + ModuleTypeHidden = "hidden" +) + // ModuleMeta holds metadata declared at the top level of a spec file. type ModuleMeta struct { Name string - Type string // e.g. "builtin" + Type string // ModuleTypeBuiltin, ModuleTypePlugin, or ModuleTypeHidden Desc string Core bool // true for CLI-internal modules (auth, mgmt) that are hidden from "list module" HelpText string // contents of .help.txt, empty if none diff --git a/pkg/spec/vibeapps.spec.yaml b/pkg/spec/vibeapps.spec.yaml index acb20aa9..62fc48ed 100644 --- a/pkg/spec/vibeapps.spec.yaml +++ b/pkg/spec/vibeapps.spec.yaml @@ -1,7 +1,6 @@ spec_version: 1 -module_type: builtin +module_type: hidden module_desc: Harness Vibe Apps — AI-generated micro-apps built and deployed via the Launchpad -harness_internal: true help_text: | ## Vibe Apps (vibeapps) diff --git a/pkg/specloader/specloader.go b/pkg/specloader/specloader.go index 2042c560..933dc64f 100644 --- a/pkg/specloader/specloader.go +++ b/pkg/specloader/specloader.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "strings" @@ -18,6 +19,7 @@ import ( "github.com/harness/cli/pkg/hlog" "github.com/harness/cli/pkg/registry" "github.com/harness/cli/pkg/spec" + "github.com/harness/cli/pkg/strutil" ) const ( @@ -35,14 +37,13 @@ type specVersionOnly struct { } type specFile struct { - SpecVersion int `yaml:"spec_version"` - ModuleType string `yaml:"module_type"` - ModuleDesc string `yaml:"module_desc"` - ModuleCore bool `yaml:"module_core"` - HelpText string `yaml:"help_text"` - HarnessInternal bool `yaml:"harness_internal,omitempty"` - Nouns []spec.NounDef `yaml:"nouns"` - Commands []*spec.CommandSpec `yaml:"commands"` + SpecVersion int `yaml:"spec_version"` + ModuleType string `yaml:"module_type"` + ModuleDesc string `yaml:"module_desc"` + ModuleCore bool `yaml:"module_core"` + HelpText string `yaml:"help_text"` + Nouns []spec.NounDef `yaml:"nouns"` + Commands []*spec.CommandSpec `yaml:"commands"` // Host-owned provenance, present only in ~/.harness/spec plugin specs. Version string `yaml:"version,omitempty"` BinaryPath string `yaml:"binary_path,omitempty"` @@ -72,14 +73,18 @@ func specParseError(name string, data []byte, parseErr error) error { // instead of being masked by an embedded copy. Only their noun ownership is // recorded (best-effort, for "plugin not installed" error messages). func LoadSpecs(reg *registry.Registry) error { - isHarnessUser := os.Getenv("HARNESS_ENABLE_BETA_MODULES") == "1" || - config.AnyProfileMatchesDomain("harness.io") + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("spec: load config: %w", err) + } + harnessDomainMatch := config.AnyProfileMatchesDomain("harness.io") for _, name := range spec.Files() { data, err := spec.Read(name) if err != nil { return fmt.Errorf("spec: read %s: %w", name, err) } - if err := loadSpecData(reg, name, data, isHarnessUser, false); err != nil { + enabled := isModuleEnabled(moduleNameFromFile(name), cfg, harnessDomainMatch) + if err := loadSpecData(reg, name, data, enabled, false); err != nil { return err } } @@ -88,22 +93,48 @@ func LoadSpecs(reg *registry.Registry) error { if err != nil { return fmt.Errorf("spec: read %s: %w", name, err) } - if err := recordPluginNouns(reg, name, data, isHarnessUser); err != nil { + enabled := isModuleEnabled(moduleNameFromFile(name), cfg, harnessDomainMatch) + if err := recordPluginNouns(reg, name, data, enabled); err != nil { return err } } - return LoadHomeSpecs(reg, isHarnessUser) + return LoadHomeSpecs(reg, cfg, harnessDomainMatch) +} + +// isModuleEnabled resolves, for a module_type: hidden module, whether it's +// enabled for this invocation. Checked in order, first match wins: +// 1. HARNESS_CLI_ENABLED_MODULES, if set at all (even to "") — a hard +// allowlist override of everything below. +// 2. HARNESS_ENABLE_BETA_MODULES=1 enables everything; "0" forces off the +// harness.io auto-detect but still falls through to the config file. +// 3. harnessDomainMatch (a saved profile's email is @harness.io). +// 4. cfg.EnabledModules (persisted by `install module `). +func isModuleEnabled(name string, cfg *config.Config, harnessDomainMatch bool) bool { + if raw, ok := os.LookupEnv(hbase.EnvEnabledModules); ok { + return slices.Contains(strutil.SplitCSV(raw), name) + } + switch os.Getenv(hbase.EnvEnableBetaModules) { + case "1": + return true + case "0": + // explicit force-off of the harness.io auto-detect; still falls through to the config check below + default: + if harnessDomainMatch { + return true + } + } + return slices.Contains(cfg.EnabledModules, name) } // recordPluginNouns records which nouns an embedded plugin spec owns, without -// registering any of its commands. A harness_internal plugin spec records -// nothing for non-Harness users, matching how builtin specs are gated. -func recordPluginNouns(reg *registry.Registry, name string, data []byte, isHarnessUser bool) error { +// registering any of its commands. A module_type: hidden plugin spec records +// nothing when not enabled, matching how builtin specs are gated. +func recordPluginNouns(reg *registry.Registry, name string, data []byte, enabled bool) error { f, err := parseSpecFile(reg, name, data) if err != nil { return err } - if f.HarnessInternal && !isHarnessUser { + if f.ModuleType == spec.ModuleTypeHidden && !enabled { return nil } reg.RecordPluginOwnedNouns(moduleNameFromFile(name), f.Nouns) @@ -114,7 +145,7 @@ func recordPluginNouns(reg *registry.Registry, name string, data []byte, isHarne // of truth for dynamically-installed plugins). A missing directory is not an // error. Home specs whose module name collides with an already-registered // (embedded) module are skipped with a stderr warning — embedded always wins. -func LoadHomeSpecs(reg *registry.Registry, isHarnessUser bool) error { +func LoadHomeSpecs(reg *registry.Registry, cfg *config.Config, harnessDomainMatch bool) error { dir := HomeSpecDir() entries, err := os.ReadDir(dir) if err != nil { @@ -166,7 +197,8 @@ func LoadHomeSpecs(reg *registry.Registry, isHarnessUser bool) error { hlog.Warn("skipping unreadable plugin spec", "file", path, "err", readErr) continue } - if err := loadSpecData(reg, name, data, isHarnessUser, true); err != nil { + enabled := isModuleEnabled(module, cfg, harnessDomainMatch) + if err := loadSpecData(reg, name, data, enabled, true); err != nil { // A single bad plugin spec must not take down the whole CLI. hlog.Warn("skipping invalid plugin spec", "file", path, "err", err) continue @@ -247,19 +279,22 @@ func parseSpecFile(reg *registry.Registry, name string, data []byte) (*specFile, // // Embedded specs pass fromSpecDir=false: they are load-order-first and validated // by check:specs, so a duplicate there is a build bug we surface loudly. -func loadSpecData(reg *registry.Registry, name string, data []byte, isHarnessUser, fromSpecDir bool) error { +func loadSpecData(reg *registry.Registry, name string, data []byte, enabled, fromSpecDir bool) error { module := moduleNameFromFile(name) f, err := parseSpecFile(reg, name, data) if err != nil { return err } - if f.HarnessInternal && !isHarnessUser { - return nil + if f.ModuleType == spec.ModuleTypeHidden { + reg.RecordHiddenModule(spec.ModuleMeta{Name: module, Type: f.ModuleType, Desc: f.ModuleDesc}) + if !enabled { + return nil + } } if fromSpecDir { // Top-level fields an installed plugin is not allowed to declare. // module_core would hide the module from `list module` and mark it a - // CLI-internal namespace — reserved for builtins. harness_internal is + // CLI-internal namespace — reserved for builtins. module_type: hidden is // deliberately still permitted for plugins. if f.ModuleCore { return fmt.Errorf("plugin spec %q may not set module_core", name) diff --git a/pkg/specloader/specloader_test.go b/pkg/specloader/specloader_test.go index 3b24a3c6..3845171f 100644 --- a/pkg/specloader/specloader_test.go +++ b/pkg/specloader/specloader_test.go @@ -5,11 +5,14 @@ package specloader import ( "bytes" + "os" "strings" "testing" "go.yaml.in/yaml/v3" + "github.com/harness/cli/pkg/config" + "github.com/harness/cli/pkg/hbase" "github.com/harness/cli/pkg/registry" "github.com/harness/cli/pkg/spec" ) @@ -39,7 +42,7 @@ func TestEmbeddedSpecPartition(t *testing.T) { if err := yaml.Unmarshal(data, &f); err != nil { t.Fatalf("parse %s: %v", name, err) } - if gotPlugin := f.ModuleType == "plugin"; gotPlugin != wantPlugin { + if gotPlugin := f.ModuleType == spec.ModuleTypePlugin; gotPlugin != wantPlugin { if wantPlugin { t.Errorf("%s is in spec.PluginFiles() but declares module_type: %q — remove it from pluginSpecFiles", name, f.ModuleType) } else { @@ -131,6 +134,165 @@ commands: } } +// TestIsModuleEnabled covers the priority sequence a module_type: hidden +// module is resolved through: HARNESS_CLI_ENABLED_MODULES (hard override, +// including set-and-empty), then HARNESS_ENABLE_BETA_MODULES, then the +// harness.io auto-detect, then the config file. +func TestIsModuleEnabled(t *testing.T) { + cfgWith := func(names ...string) *config.Config { return &config.Config{EnabledModules: names} } + + tests := []struct { + name string + module string + cfg *config.Config + harnessDomainMatch bool + enabledModulesEnv *string // nil = unset + enableBetaEnv string + want bool + }{ + { + name: "hidden module with nothing enabling it is disabled", + module: "autonomous_work", + cfg: cfgWith(), + want: false, + }, + { + name: "HARNESS_CLI_ENABLED_MODULES allowlist hit", + module: "autonomous_work", + cfg: cfgWith(), + enabledModulesEnv: strPtr("foo, autonomous_work, bar"), + want: true, + }, + { + name: "HARNESS_CLI_ENABLED_MODULES set-and-empty disables everything, even with domain match and config entry", + module: "autonomous_work", + cfg: cfgWith("autonomous_work"), + harnessDomainMatch: true, + enabledModulesEnv: strPtr(""), + want: false, + }, + { + name: "HARNESS_ENABLE_BETA_MODULES=1 enables everything", + module: "autonomous_work", + cfg: cfgWith(), + enableBetaEnv: "1", + want: true, + }, + { + name: "HARNESS_ENABLE_BETA_MODULES=0 suppresses auto-detect but still honors config", + module: "autonomous_work", + cfg: cfgWith("autonomous_work"), + harnessDomainMatch: true, + enableBetaEnv: "0", + want: true, + }, + { + name: "HARNESS_ENABLE_BETA_MODULES=0 with no config entry stays disabled despite domain match", + module: "autonomous_work", + cfg: cfgWith(), + harnessDomainMatch: true, + enableBetaEnv: "0", + want: false, + }, + { + name: "unset + harness.io domain match auto-enables", + module: "autonomous_work", + cfg: cfgWith(), + harnessDomainMatch: true, + want: true, + }, + { + name: "plain config-file enablement with no env vars set", + module: "autonomous_work", + cfg: cfgWith("autonomous_work"), + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setEnvOrUnset(t, hbase.EnvEnabledModules, tc.enabledModulesEnv) + t.Setenv(hbase.EnvEnableBetaModules, tc.enableBetaEnv) + + got := isModuleEnabled(tc.module, tc.cfg, tc.harnessDomainMatch) + if got != tc.want { + t.Errorf("isModuleEnabled(%q) = %v, want %v", tc.module, got, tc.want) + } + }) + } +} + +func strPtr(s string) *string { return &s } + +// setEnvOrUnset sets key to *val, or ensures it's unset when val is nil — +// distinct from setting it to "", since isModuleEnabled uses os.LookupEnv to +// tell "unset" from "set to empty". Restores the original value on cleanup. +func setEnvOrUnset(t *testing.T, key string, val *string) { + orig, hadOrig := os.LookupEnv(key) + t.Cleanup(func() { + if hadOrig { + os.Setenv(key, orig) + } else { + os.Unsetenv(key) + } + }) + if val == nil { + os.Unsetenv(key) + return + } + os.Setenv(key, *val) +} + +// TestLoadSpecData_HiddenModule verifies that a module_type: hidden module is +// fully absent from the registry when not enabled (nouns/commands unregistered, +// no ModuleMeta) except for the RecordHiddenModule stub that lets `install +// module` find it by name, and that it registers normally once enabled. +func TestLoadSpecData_HiddenModule(t *testing.T) { + specYAML := []byte(` +spec_version: 1 +module_type: hidden +module_desc: a hidden module +nouns: + - noun: widget + fields: + - id: identifier + expr: it.id +`) + + t.Run("disabled: absent from registry, recorded as hidden", func(t *testing.T) { + reg := registry.New() + if err := loadSpecData(reg, "widget.spec.yaml", specYAML, false, false); err != nil { + t.Fatalf("loadSpecData: %v", err) + } + if reg.HasModule("widget") { + t.Error("disabled hidden module should not be registered") + } + if reg.GetNoun("widget") != nil { + t.Error("disabled hidden module's noun should not be registered") + } + m := reg.GetHiddenModule("widget") + if m == nil { + t.Fatal("GetHiddenModule(\"widget\") = nil, want a stub recorded regardless of enablement") + } + if m.Desc != "a hidden module" { + t.Errorf("GetHiddenModule(\"widget\").Desc = %q, want %q", m.Desc, "a hidden module") + } + }) + + t.Run("enabled: registers normally", func(t *testing.T) { + reg := registry.New() + if err := loadSpecData(reg, "widget.spec.yaml", specYAML, true, false); err != nil { + t.Fatalf("loadSpecData: %v", err) + } + if !reg.HasModule("widget") { + t.Error("enabled hidden module should be registered") + } + if reg.GetNoun("widget") == nil { + t.Error("enabled hidden module's noun should be registered") + } + }) +} + // parseAndLoad mirrors LoadSpec but accepts raw bytes instead of reading from // embed.FS, allowing unit tests to exercise the parse-and-register path. func parseAndLoad(reg *registry.Registry, name string, data []byte) error { diff --git a/pkg/strutil/strutil.go b/pkg/strutil/strutil.go index e3945a47..a58081fb 100644 --- a/pkg/strutil/strutil.go +++ b/pkg/strutil/strutil.go @@ -6,6 +6,7 @@ package strutil import ( "fmt" "strconv" + "strings" ) // Stringify renders a value for display, avoiding Go's default scientific @@ -17,6 +18,18 @@ func Stringify(v any) string { return fmt.Sprint(v) } +// SplitCSV splits a comma-separated value, trimming whitespace around each +// entry and dropping empty ones (so both "foo,bar" and "foo, bar" work). +func SplitCSV(raw string) []string { + var out []string + for _, part := range strings.Split(raw, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out +} + // Levenshtein returns the edit distance between two strings. func Levenshtein(a, b string) int { ra, rb := []rune(a), []rune(b)