You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Implement pkg/vmcp/cli/init.go — the Init function that discovers running workloads in a named ToolHive group and renders a starter YAML config file pre-populated with those backends. Include unit tests for template rendering and output validation. This gives users a safe, low-friction starting point for config-file-driven thv vmcp serve without requiring manual YAML authoring.
Context
RFC THV-0059 specifies a thv vmcp init subcommand that acts as a scaffolding assistant: it calls the local group workload discoverer, converts each discovered workload into a StaticBackendConfig, and renders a commented YAML file that a user can review, customize, and then pass to thv vmcp serve --config. The rendered file must include groupRef, a backends list derived from live workloads, and inline comments explaining each section.
This item is purely additive — it creates one new file, pkg/vmcp/cli/init.go, alongside the serve.go and validate.go files produced by #4879. No existing files are modified here. The Init function will be wired into the thv vmcp init Cobra subcommand in #4885.
Dependencies: Depends on #4879 (the pkg/vmcp/cli/ package must exist before init.go can be added to it) Blocks: #4885
Acceptance Criteria
pkg/vmcp/cli/init.go exists with the required SPDX copyright header (// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. / // SPDX-License-Identifier: Apache-2.0)
Init(ctx context.Context, cfg InitConfig) error is exported and is the sole public entry point
InitConfig includes at minimum: GroupName string, OutputPath string (empty means stdout), and Discoverer workloads.Discoverer (injected dependency, not constructed internally)
Init calls cfg.Discoverer.ListWorkloadsInGroup(ctx, cfg.GroupName) and then GetWorkloadAsVMCPBackend for each discovered workload to build the backend list
Workloads returned as nil from GetWorkloadAsVMCPBackend (not yet accessible) are skipped gracefully with a debug log entry
The rendered YAML includes groupRef, a backends list (one entry per accessible workload), and an outgoingAuth stub with source: inline
Each rendered backend block contains at minimum: name, url, and transport fields
Rendered YAML includes inline comments (#) explaining the purpose of key sections (groupRef, backends, outgoingAuth), making the file self-documenting
When OutputPath is empty or -, the rendered content is written to the provided io.Writer (or os.Stdout by default)
When OutputPath is a non-empty file path, the content is written atomically (temp file + rename, or os.WriteFile with 0o600 permissions)
go build ./pkg/vmcp/cli/... succeeds with no errors
go vet ./pkg/vmcp/cli/... passes with no issues
No new external Go module dependencies introduced
Unit tests in pkg/vmcp/cli/init_test.go cover the cases listed in the Testing Strategy below
All tests pass
Code reviewed and approved
Technical Approach
Recommended Implementation
Create pkg/vmcp/cli/init.go in the package established by #4879. The function accepts an InitConfig struct that carries the group name, output destination, and an injected workloads.Discoverer interface — this keeps the function fully testable without a real container runtime.
The rendering pipeline is:
Call ListWorkloadsInGroup to enumerate workload names.
For each workload, call GetWorkloadAsVMCPBackend to resolve its URL and transport type. Skip any that return nil (not yet accessible).
Map each resolved *vmcp.Backend to a config.StaticBackendConfig using the backend's Name, BaseURL, and TransportType fields.
Build a config.Config value with Group set to the group name, Backends set to the collected slice, and OutgoingAuth stubbed as {Source: "inline"}.
Render to YAML using gopkg.in/yaml.v3. Since gopkg.in/yaml.v3 does not support inline comments natively, use a text/template to produce the final commented output (following the pattern in pkg/container/templates/templates.go), or marshal the struct and then prepend a YAML comment block header.
Write to OutputPath or the configured writer.
The preferred rendering approach is a text/template embedded in the source file (no separate .tmpl file is necessary for a small template). Define a ConfigTemplate constant and parse it with template.New("vmcp-init").Parse(ConfigTemplate). Execute the template with a data struct that holds the marshalled YAML fields needed for interpolation.
Alternatively, use gopkg.in/yaml.v3's yaml.Node API to attach line comments to nodes, which is the idiomatic way to produce commented YAML without raw string templating. Either approach is acceptable; choose based on the volume of comments needed.
If no workloads are found in the group, Init should still produce a valid, commented YAML skeleton with an empty backends list and a comment explaining that no workloads were currently running.
Patterns & Frameworks
SPDX headers: Every new .go file must open with the two-line SPDX header
Dependency injection for Discoverer: Accept workloads.Discoverer in InitConfig; never construct it internally — this is required for unit testability
text/template rendering: Follow pkg/container/templates/templates.go — template.New(...).Parse(tmplStr), execute into a bytes.Buffer, write result
gopkg.in/yaml.v3 marshalling: Use yaml.Marshal to convert config.Config to bytes for the backend list sub-section, or use yaml.Node for comment-annotated output
Immutable variable assignment: Prefer x := ... over var x ...; x = ... across branches
Error wrapping: fmt.Errorf("...: %w", err) throughout; no silent failures
File output with safe permissions: os.WriteFile(path, data, 0o600) for config files (never world-readable)
Mockgen for tests: The workloads.Discoverer mock is already generated at pkg/vmcp/workloads/mocks/mock_discoverer.go — use it directly in tests
Code Pointers
pkg/vmcp/workloads/discoverer.go — Defines the Discoverer interface (ListWorkloadsInGroup, GetWorkloadAsVMCPBackend) and TypedWorkload type; this is what InitConfig.Discoverer must satisfy
pkg/vmcp/workloads/mocks/mock_discoverer.go — Pre-generated gomock mock for Discoverer; use in unit tests without any task gen run required
pkg/vmcp/config/config.go — Config, StaticBackendConfig, and OutgoingAuthConfig types; StaticBackendConfig.Name, .URL, .Transport are the fields to populate from vmcp.Backend
pkg/vmcp/types.go — Backend struct (Name, BaseURL, TransportType fields) returned by GetWorkloadAsVMCPBackend
pkg/container/templates/templates.go — Reference for text/template + embed.FS pattern; note that for init.go a small inline template constant is simpler than a separate .tmpl file
// pkg/vmcp/cli/init.go// InitConfig holds all parameters for the Init command.// Discoverer is injected rather than constructed internally to enable unit testing.typeInitConfigstruct {
// GroupName is the ToolHive group whose workloads are enumerated.GroupNamestring// OutputPath is the file path to write the generated config.// If empty or "-", content is written to Writer.OutputPathstring// Writer is used when OutputPath is empty or "-".// Defaults to os.Stdout when nil.Writer io.Writer// Discoverer resolves running workloads in the group.// In production, callers pass a *groups.CLIManager or equivalent.Discoverer workloads.Discoverer
}
// Init discovers workloads in cfg.GroupName, renders a starter vMCP YAML// config file with one backend entry per accessible workload, and writes// the result to cfg.OutputPath or cfg.Writer.funcInit(ctx context.Context, cfgInitConfig) error { ... }
The output YAML should approximate the following shape (comments are illustrative):
# Generated by `thv vmcp init`. Review and customize before use.# groupRef: the ToolHive group whose workloads are aggregated.groupRef: my-group# backends: one entry per running workload discovered in the group.backends:
- name: my-serverurl: http://127.0.0.1:45678/mcptransport: streamable-http# outgoingAuth: controls how vMCP authenticates to backends.# source: inline means auth config is fully specified here (no K8s discovery).outgoingAuth:
source: inline
Testing Strategy
Unit Tests (pkg/vmcp/cli/init_test.go)
TestInit_WritesToStdout: when OutputPath is empty, rendered YAML is written to cfg.Writer; assert groupRef and at least one backend name appear in output
TestInit_WritesToFile: when OutputPath is a valid temp-dir path, file is created with correct permissions (0o600) and contains valid YAML that gopkg.in/yaml.v3 can unmarshal into config.Config
TestInit_SkipsNilBackends: mock GetWorkloadAsVMCPBackend to return nil for one workload; assert it is omitted from the rendered backend list
TestInit_EmptyGroup: mock ListWorkloadsInGroup to return empty slice; assert rendered YAML is still valid, contains groupRef, and has an empty or absent backends key
TestInit_DiscoveryError: mock ListWorkloadsInGroup to return an error; assert Init propagates the error with a descriptive wrap
TestInit_RenderedYAMLIsValid: parse the rendered output with config.NewYAMLLoader and config.NewValidator; assert no validation errors (requires the rendered file to pass strict YAML parsing — this is the integration-style correctness check for the template)
TestInit_OutputFilePermissions: assert the written file has 0o600 permissions on Linux/Darwin
Integration Tests
Not required for this item; the TestInit_RenderedYAMLIsValid unit test acts as the integration-level correctness check
Edge Cases
GroupName empty string: return a descriptive error before calling Discoverer
Discoverer nil: return a descriptive error at the top of Init
OutputPath refers to a non-existent parent directory: return a wrapped os.PathError
Description
Implement
pkg/vmcp/cli/init.go— theInitfunction that discovers running workloads in a named ToolHive group and renders a starter YAML config file pre-populated with those backends. Include unit tests for template rendering and output validation. This gives users a safe, low-friction starting point for config-file-driventhv vmcp servewithout requiring manual YAML authoring.Context
RFC THV-0059 specifies a
thv vmcp initsubcommand that acts as a scaffolding assistant: it calls the local group workload discoverer, converts each discovered workload into aStaticBackendConfig, and renders a commented YAML file that a user can review, customize, and then pass tothv vmcp serve --config. The rendered file must includegroupRef, abackendslist derived from live workloads, and inline comments explaining each section.This item is purely additive — it creates one new file,
pkg/vmcp/cli/init.go, alongside theserve.goandvalidate.gofiles produced by #4879. No existing files are modified here. TheInitfunction will be wired into thethv vmcp initCobra subcommand in #4885.Dependencies: Depends on #4879 (the
pkg/vmcp/cli/package must exist beforeinit.gocan be added to it)Blocks: #4885
Acceptance Criteria
pkg/vmcp/cli/init.goexists with the required SPDX copyright header (// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc./// SPDX-License-Identifier: Apache-2.0)Init(ctx context.Context, cfg InitConfig) erroris exported and is the sole public entry pointInitConfigincludes at minimum:GroupName string,OutputPath string(empty means stdout), andDiscoverer workloads.Discoverer(injected dependency, not constructed internally)Initcallscfg.Discoverer.ListWorkloadsInGroup(ctx, cfg.GroupName)and thenGetWorkloadAsVMCPBackendfor each discovered workload to build the backend listnilfromGetWorkloadAsVMCPBackend(not yet accessible) are skipped gracefully with a debug log entrygroupRef, abackendslist (one entry per accessible workload), and anoutgoingAuthstub withsource: inlinename,url, andtransportfields#) explaining the purpose of key sections (groupRef, backends, outgoingAuth), making the file self-documentingOutputPathis empty or-, the rendered content is written to the providedio.Writer(oros.Stdoutby default)OutputPathis a non-empty file path, the content is written atomically (temp file + rename, oros.WriteFilewith0o600permissions)go build ./pkg/vmcp/cli/...succeeds with no errorsgo vet ./pkg/vmcp/cli/...passes with no issuespkg/vmcp/cli/init_test.gocover the cases listed in the Testing Strategy belowTechnical Approach
Recommended Implementation
Create
pkg/vmcp/cli/init.goin the package established by #4879. The function accepts anInitConfigstruct that carries the group name, output destination, and an injectedworkloads.Discovererinterface — this keeps the function fully testable without a real container runtime.The rendering pipeline is:
ListWorkloadsInGroupto enumerate workload names.GetWorkloadAsVMCPBackendto resolve its URL and transport type. Skip any that returnnil(not yet accessible).*vmcp.Backendto aconfig.StaticBackendConfigusing the backend'sName,BaseURL, andTransportTypefields.config.Configvalue withGroupset to the group name,Backendsset to the collected slice, andOutgoingAuthstubbed as{Source: "inline"}.gopkg.in/yaml.v3. Sincegopkg.in/yaml.v3does not support inline comments natively, use atext/templateto produce the final commented output (following the pattern inpkg/container/templates/templates.go), or marshal the struct and then prepend a YAML comment block header.OutputPathor the configured writer.The preferred rendering approach is a
text/templateembedded in the source file (no separate.tmplfile is necessary for a small template). Define aConfigTemplateconstant and parse it withtemplate.New("vmcp-init").Parse(ConfigTemplate). Execute the template with a data struct that holds the marshalled YAML fields needed for interpolation.Alternatively, use
gopkg.in/yaml.v3'syaml.NodeAPI to attach line comments to nodes, which is the idiomatic way to produce commented YAML without raw string templating. Either approach is acceptable; choose based on the volume of comments needed.If no workloads are found in the group,
Initshould still produce a valid, commented YAML skeleton with an emptybackendslist and a comment explaining that no workloads were currently running.Patterns & Frameworks
.gofile must open with the two-line SPDX headerpackage cli(matchingpkg/vmcp/cli/)Discoverer: Acceptworkloads.DiscovererinInitConfig; never construct it internally — this is required for unit testabilitytext/templaterendering: Followpkg/container/templates/templates.go—template.New(...).Parse(tmplStr), execute into abytes.Buffer, write resultgopkg.in/yaml.v3marshalling: Useyaml.Marshalto convertconfig.Configto bytes for the backend list sub-section, or useyaml.Nodefor comment-annotated outputx := ...overvar x ...; x = ...across branchesfmt.Errorf("...: %w", err)throughout; no silent failuresos.WriteFile(path, data, 0o600)for config files (never world-readable)workloads.Discoverermock is already generated atpkg/vmcp/workloads/mocks/mock_discoverer.go— use it directly in testsCode Pointers
pkg/vmcp/workloads/discoverer.go— Defines theDiscovererinterface (ListWorkloadsInGroup,GetWorkloadAsVMCPBackend) andTypedWorkloadtype; this is whatInitConfig.Discoverermust satisfypkg/vmcp/workloads/mocks/mock_discoverer.go— Pre-generated gomock mock forDiscoverer; use in unit tests without anytask genrun requiredpkg/vmcp/config/config.go—Config,StaticBackendConfig, andOutgoingAuthConfigtypes;StaticBackendConfig.Name,.URL,.Transportare the fields to populate fromvmcp.Backendpkg/vmcp/types.go—Backendstruct (Name,BaseURL,TransportTypefields) returned byGetWorkloadAsVMCPBackendpkg/container/templates/templates.go— Reference fortext/template+embed.FSpattern; note that forinit.goa small inline template constant is simpler than a separate.tmplfileplanning-20260416-105417/scripts-20260416-111210/item_#4879_issue_body.md— Extract shared vMCP logic intopkg/vmcp/cli/(serve + validate) #4879 spec; defines thepkg/vmcp/cli/package structure andServeConfig/ValidateConfigshapes thatInitConfigshould be consistent withComponent Interfaces
The output YAML should approximate the following shape (comments are illustrative):
Testing Strategy
Unit Tests (
pkg/vmcp/cli/init_test.go)TestInit_WritesToStdout: whenOutputPathis empty, rendered YAML is written tocfg.Writer; assertgroupRefand at least one backend name appear in outputTestInit_WritesToFile: whenOutputPathis a valid temp-dir path, file is created with correct permissions (0o600) and contains valid YAML thatgopkg.in/yaml.v3can unmarshal intoconfig.ConfigTestInit_SkipsNilBackends: mockGetWorkloadAsVMCPBackendto returnnilfor one workload; assert it is omitted from the rendered backend listTestInit_EmptyGroup: mockListWorkloadsInGroupto return empty slice; assert rendered YAML is still valid, containsgroupRef, and has an empty or absentbackendskeyTestInit_DiscoveryError: mockListWorkloadsInGroupto return an error; assertInitpropagates the error with a descriptive wrapTestInit_RenderedYAMLIsValid: parse the rendered output withconfig.NewYAMLLoaderandconfig.NewValidator; assert no validation errors (requires the rendered file to pass strict YAML parsing — this is the integration-style correctness check for the template)TestInit_OutputFilePermissions: assert the written file has0o600permissions on Linux/DarwinIntegration Tests
TestInit_RenderedYAMLIsValidunit test acts as the integration-level correctness checkEdge Cases
GroupNameempty string: return a descriptive error before callingDiscovererDiscoverernil: return a descriptive error at the top ofInitOutputPathrefers to a non-existent parent directory: return a wrappedos.PathErrorOut of Scope
Initinto a Cobra subcommand (cmd/thv/app/vmcp.go) — that is Addthv vmcp initsubcommand #4885thv vmcp serve#4886EmbeddingServiceManager— that is Implement EmbeddingServiceManager in pkg/vmcp/cli/ #4884cmd/vmcp/app/commands.go— that is Thin-wrap standalone vmcp binary over pkg/vmcp/cli/ #4880thv vmcp init— covered in E2E tests: quick mode and config-file mode #4888pkg/vmcp/config/,pkg/vmcp/workloads/, or any existing packageReferences
initcommand design including template shape and flag namespkg/vmcp/workloads/discoverer.go—Discovererinterface this item depends onpkg/container/templates/templates.go— Template rendering pattern to follow.claude/rules/go-style.md— SPDX headers, error wrapping, file permission conventions.claude/rules/testing.md— gomock usage (go.uber.org/mock), parallel test safety,t.Cleanupfor teardown