a9s-add-resource
Blueprint for adding a new AWS resource type to a9s — split into CODER steps (1-8) and QA steps (9-13) with templates
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Blueprint for adding a new AWS resource type to a9s — split into CODER steps (1-8) and QA steps (9-13) with templates
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
Recipe for adding a list-view attention column to a resource type — handles Tier A (existing field), Tier B (computed/Wave-2), and the FieldUpdates promotion path. Optimized to avoid the rediscovery overhead that plagued the first Tier A sweep.
Bug-hunting workflow for a9s using real AWS data, grounded in docs first, then live behavior, then code. Use for EC2-detail and related-view QA where existing tests may be misleading.
Shared rules for all a9s agents — shell rules, package access, build/test commands
Create or update the single-source fixture file for a resource type under `core/demo/fixtures/<shortName>.go`. Use when the coder is given a fixture-creation task during phase 6 of `a9s-implement-resource`, or any time a resource type needs new demo/test data (new states, new edge cases, new spec coverage). Takes a plain-language fixture list (typically from `docs/resources/<shortName>-impl-plan.md` §2) and produces realistic, graph-connected AWS SDK typed fakes that both `./a9s --demo` and the unit test suite import. Cross-references sibling fixture files so every related-panel pivot renders a non-zero count. Never creates orphan fixtures — a DB instance fixture without matching KMS keys, security groups, alarms, and CloudTrail events is a bug, not a feature. Adversarial / malformed fixtures (nil pointers, error cases) stay inline in tests and are explicitly out of scope for this skill.
End-to-end workflow for implementing a GitHub issue — from analysis through QA stories, design, scoped tasks, implementation, pre-release checks, and release prep. Use for any issue that is NOT a new-resource or child-view (those have their own skills).
Implement (or re-implement) an a9s resource type from its golden UX/UI spec at `docs/resources/<shortName>.md`. Use whenever the user asks to "implement", "wire up", "finish", "fix", or "rebuild" a resource that already has a spec doc — including cases where partial, stubbed, or buggy code exists and must be replaced. Treats the spec doc as the contract and the existing implementation as disposable. Reads ONLY the spec doc and four contract-surface files (`<shortName>_interfaces.go`, `<shortName>_related.go`, `<shortName>_issue_enrichment.go`, `<shortName>_detail_enrichment.go`); never reads existing tests or fetchers. Dispatches `a9s-qa` and `a9s-coder` with scoped file lists. Cleans up stubs and "pretend to work" code tied to TBDs. Trigger this for any request that names a resource shortName and asks for implementation, tests, fixtures, or cleanup — even if the user doesn't explicitly mention the spec doc.
| name | a9s-add-resource |
| description | Blueprint for adding a new AWS resource type to a9s — split into CODER steps (1-8) and QA steps (9-13) with templates |
| disable-model-invocation | true |
Two agents, two tracks. The architect scopes both tasks. Coder and QA can run in parallel for resource types (pattern is rigid).
You MUST have a scoped task from the architect with:
RegisterFieldKeys)If you don't have this, STOP. Reply with REJECTED and ask for architect scope.
| Steps | Owner | Writes to |
|---|---|---|
| 1-8 (implementation) | a9s-coder | internal/, cmd/, .a9s/ |
| 9-13 (tests) | a9s-qa | tests/unit/ |
Coder MUST NOT write test files. QA MUST NOT write production code.
Pick the right pattern based on the architect spec:
ServiceClientsinit() passes existing client: e.g., c.EC2 for VPC/SGExistingClient: EC2// Pattern B init() — note c.EC2 not c.VPC
func init() {
resource.RegisterFieldKeys("vpc", []string{"vpc_id", "name", "state", "cidr", "is_default"})
resource.RegisterPaginated("vpc", func(ctx context.Context, clients interface{}, continuationToken string) (resource.FetchResult, error) {
c, ok := clients.(*ServiceClients)
if !ok || c == nil {
return resource.FetchResult{}, fmt.Errorf("AWS clients not initialized")
}
return FetchVPCsPage(ctx, c.EC2, continuationToken) // reuses EC2 client
})
}
init() passes same client multiple timesAPI Sequence: with ordered steps// Pattern C init() — same client passed 3 times for 3 interfaces
func init() {
resource.RegisterFieldKeys("ng", []string{"name", "cluster", "status", "instance_types", "desired", "min", "max"})
resource.RegisterPaginated("ng", func(ctx context.Context, clients interface{}, continuationToken string) (resource.FetchResult, error) {
c, ok := clients.(*ServiceClients)
if !ok || c == nil {
return resource.FetchResult{}, fmt.Errorf("AWS clients not initialized")
}
// Multi-step fetchers wrap entire sequence, return IsTruncated: false
resources, err := FetchNodeGroups(ctx, c.EKS, c.EKS, c.EKS)
if err != nil {
return resource.FetchResult{}, err
}
return resource.FetchResult{
Resources: resources,
Pagination: &resource.PaginationMeta{IsTruncated: false, TotalHint: len(resources), PageSize: len(resources)},
}, nil
})
}
// Pattern C fetcher signature — multiple API interfaces
func FetchNodeGroups(
ctx context.Context,
listClustersAPI EKSListClustersAPI,
listNodegroupsAPI EKSListNodegroupsAPI,
describeNodegroupAPI EKSDescribeNodegroupAPI,
) ([]resource.Resource, error) {
// Step 1: List parents
// Step 2: For each parent, list children
// Step 3: For each child, describe
}
Pattern C mocks use map-based outputs keyed by parent resource name:
type mockEKSListNodegroupsClient struct {
outputs map[string]*eks.ListNodegroupsOutput // keyed by cluster name
err error
}
type mockEKSDescribeNodegroupClient struct {
outputs map[string]*eks.DescribeNodegroupOutput // keyed by "cluster/nodegroup"
err error
}
Some resources have no Name field — extract from Tags:
name := ""
for _, tag := range item.Tags {
if tag.Key != nil && *tag.Key == "Name" {
if tag.Value != nil {
name = *tag.Value
}
break
}
}
Some resources have no status concept. Set Status: "":
r := resource.Resource{
ID: groupID,
Name: groupName,
Status: "", // Security Groups have no status
// ...
}
Guard for nil nested structs before accessing fields:
desiredSize := ""
if ng.ScalingConfig != nil {
if ng.ScalingConfig.DesiredSize != nil {
desiredSize = fmt.Sprintf("%d", *ng.ScalingConfig.DesiredSize)
}
}
core/aws/{type}.go (NEW FILE)IMPORTANT: Module path is github.com/k2m30/a9s/v3/... (the /v3 suffix is required).
The Resource struct has exactly 5 fields: ID, Name, Status, Fields, RawStruct. There is NO DetailData or RawJSON field. Detail/YAML views use RawStruct + fieldpath.ExtractSubtree for rendering.
package aws
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go-v2/service/{service}"
"github.com/k2m30/a9s/v3/core/resource"
)
func init() {
resource.RegisterFieldKeys("{shortname}", []string{"key1", "key2", ...})
resource.RegisterPaginated("{shortname}", func(ctx context.Context, clients interface{}, continuationToken string) (resource.FetchResult, error) {
c, ok := clients.(*ServiceClients)
if !ok || c == nil {
return resource.FetchResult{}, fmt.Errorf("AWS clients not initialized")
}
return Fetch{TypeName}Page(ctx, c.{ServiceField}, continuationToken)
})
}
// Fetch{TypeName} is a backward-compat wrapper that fetches ALL pages.
func Fetch{TypeName}(ctx context.Context, api {InterfaceName}) ([]resource.Resource, error) {
var all []resource.Resource
token := ""
for {
result, err := Fetch{TypeName}Page(ctx, api, token)
if err != nil { return nil, err }
all = append(all, result.Resources...)
if result.Pagination == nil || !result.Pagination.IsTruncated { break }
token = result.Pagination.NextToken
}
return all, nil
}
// Fetch{TypeName}Page fetches a single page of resources.
func Fetch{TypeName}Page(ctx context.Context, api {InterfaceName}, continuationToken string) (resource.FetchResult, error) {
input := &{service}.{APICall}Input{}
if continuationToken != "" {
input.NextToken = &continuationToken
}
output, err := api.{APICall}(ctx, input)
if err != nil {
return nil, fmt.Errorf("fetching {TypeName}: %w", err)
}
for _, item := range output.{ResultField} {
// Extract ID, Name, Status from SDK struct
id := /* ... */
name := /* ... */
status := /* ... */
// Build Fields map matching column keys from types_{category}.go
fields := map[string]string{
"key1": /* ... */,
}
resources = append(resources, resource.Resource{
ID: id,
Name: name,
Status: status,
Fields: fields,
RawStruct: item,
})
}
if output.NextToken == nil {
break
}
nextToken = output.NextToken
}
return resources, nil
}
core/aws/<service>_interfaces.go (APPEND to the service's existing file)// {TypeName}{APICall}API defines the interface for the {Service} {APICall} operation.
type {TypeName}{APICall}API interface {
{APICall}(ctx context.Context, params *{service}.{APICall}Input, optFns ...func(*{service}.Options)) (*{service}.{APICall}Output, error)
}
core/aws/client.go (ADD TO ServiceClients + CreateServiceClients)If the service is NEW (not already in ServiceClients), add field and constructor:
Add field to ServiceClients struct:
{ServiceField} *{service}.Client
Add constructor line in CreateServiceClients:
{ServiceField}: {service}.NewFromConfig(cfg),
Add import if new service.
If the service already exists (Pattern B), skip this step.
core/resource/types_{category}.go (APPEND to category function)The type definitions are split by category. Append to the correct file:
types_compute.go → computeResourceTypes()types_containers.go → containersResourceTypes()types_networking.go → networkingResourceTypes()types_databases.go → databasesResourceTypes()types_monitoring.go → monitoringResourceTypes()types_messaging.go → messagingResourceTypes()types_secrets.go → secretsResourceTypes()types_dns_cdn.go → dnsCdnResourceTypes()types_security.go → securityResourceTypes()types_cicd.go → cicdResourceTypes()types_data.go → dataResourceTypes()types_backup.go → backupResourceTypes(){
Name: "{Display Name}",
ShortName: "{shortname}",
Aliases: []string{"{alias1}", "{alias2}"},
Category: "{CATEGORY}",
Columns: []Column{
{Key: "key1", Title: "Title1", Width: 28, Sortable: true},
// ... from architect spec
},
},
core/config/defaults_{category}.go (ADD to category function)The defaults are split by category. Append to the matching defaults_{category}.go file's {category}DefaultViews() map:
"{shortname}": {
List: []ListColumn{
{Title: "Title1", Path: "SDKFieldName", Width: 28},
// ... matching types_{category}.go columns
},
Detail: []string{
"SDKField1", "SDKField2", // from architect spec
},
},
.a9s/views/{shortname}.yaml (REGENERATE via viewsgen)Run: go run ./cmd/viewsgen/
cmd/refgen/main.go (APPEND to resources slice){"{shortname}", "{service}types.{SDKType}", reflect.TypeOf({service}types.{SDKType}{})},
Add import if new service types package.
Every resource type needs demo mode fixtures. All services use typed fakes — there is no legacy fixture store.
Add fixture data to core/demo/fixtures/<service>.go and extend the matching fake in core/demo/fakes/<service>.go. If the service already has a fake, add new SDK-typed fixture objects to the existing fixture struct and implement any new methods on the fake. If the service is new, create both files following the EC2 pattern.
Wire the new fake into core/demo/client.go (NewServiceClients()) if it's a new service.
Reference: See core/demo/fixtures/ec2.go for the canonical fixture pattern and core/demo/fakes/ec2.go for the fake pattern.
tests/unit/mocks_test.go (APPEND)// mock{TypeName}Client implements awsclient.{InterfaceName} for testing.
type mock{TypeName}Client struct {
output *{service}.{APICall}Output
err error
}
func (m *mock{TypeName}Client) {APICall}(
ctx context.Context,
params *{service}.{APICall}Input,
optFns ...func(*{service}.Options),
) (*{service}.{APICall}Output, error) {
return m.output, m.err
}
tests/unit/aws_{shortname}_test.go (NEW FILE)Write tests covering:
fmt.Errorf)CRITICAL — use exact mock value assertions, NOT == "":
// BAD — weak assertion:
if r.Fields["vpc_id"] == "" {
t.Error("Fields[vpc_id] must not be empty")
}
// GOOD — exact mock value comparison:
if r.Fields["vpc_id"] != "vpc-111" {
t.Errorf("Fields[vpc_id] = %q, want %q", r.Fields["vpc_id"], "vpc-111")
}
Every mock sets specific values (e.g., VpcId: aws.String("vpc-111")). The test MUST assert the exact value extracted by the fetcher, not just that it's non-empty. This catches mapping bugs where the wrong field is read.
tests/unit/qa_detail_v220_test.go (APPEND — package unit_test)Pattern file: tests/unit/qa_detail_v220_test.go (see realisticBackup + TestQA_Detail_Backup_*)
Helpers: tests/unit/helpers_external_test.go (buildResource, configForType, newDetailModel, ensureNoColor)
Add 1 realistic builder + 3 tests per resource type:
// 1. realistic SDK struct builder
func realistic{TypeName}() {service}types.{SDKType} {
return {service}types.{SDKType}{
{Field1}: ptrString("value1"),
{Field2}: ptrString("value2"),
// populate all fields used by defaults_{category}.go detail paths
}
}
// 2. ViewContainsExpectedFields — verify config-driven detail rendering
func TestQA_Detail_{TypeName}_ViewContainsExpectedFields(t *testing.T) {
ensureNoColor(t)
raw := realistic{TypeName}()
res := buildResource("test-id", "test-name", raw)
cfg := configForType("{shortname}")
m := newDetailModel(res, "{shortname}", cfg)
view := m.View()
if !strings.Contains(view, "value1") {
t.Errorf("{TypeName} detail should contain {Field1}, got:\n%s", view)
}
}
// 3. NilFields — zero-value SDK struct must not panic
func TestQA_Detail_{TypeName}_NilFields(t *testing.T) {
ensureNoColor(t)
raw := {service}types.{SDKType}{} // all nil/zero
res := buildResource("empty", "empty", raw)
cfg := configForType("{shortname}")
m := newDetailModel(res, "{shortname}", cfg)
view := m.View()
if view == "" {
t.Error("detail view should not be empty with nil {TypeName} fields")
}
}
// 4. FrameTitle — verify it returns the resource name
func TestQA_Detail_{TypeName}_FrameTitle(t *testing.T) {
raw := realistic{TypeName}()
res := buildResource("test-id", "test-name", raw)
cfg := configForType("{shortname}")
m := newDetailModel(res, "{shortname}", cfg)
if m.FrameTitle() != "test-name" {
t.Errorf("FrameTitle = %q, want %q", m.FrameTitle(), "test-name")
}
}
Append point: After the last TestQA_Detail_* function in qa_detail_v220_test.go (currently TestQA_Detail_Backup_FrameTitle).
For types without SDK structs (e.g., SQS uses attribute maps):
use buildResourceWithFields and pass nil for RawStruct. The detail view falls back to Fields rendering.
tests/unit/qa_yaml_v220_test.go (APPEND — package unit)Pattern file: tests/unit/qa_yaml_v220_test.go (see fixtureBackup + TestQA_YAML_Backup_*)
Helpers: yamlView() and yamlModel() from tests/unit/qa_yaml_test.go
Add 1 fixture function + 3 tests per resource type:
// Fixture returning []resource.Resource with Fields map
func fixture{TypeName}() []resource.Resource {
return []resource.Resource{{
ID: "test-id", Name: "test-name", Status: "active",
Fields: map[string]string{
"key1": "value1",
"key2": "value2",
// all column keys from types_{category}.go
},
}}
}
// 1. ViewContainsFields — all field keys and values rendered
func TestQA_YAML_{TypeName}_ViewContainsFields(t *testing.T) {
items := fixture{TypeName}()
for _, item := range items {
out := yamlView(t, item, 120, 40)
for k, v := range item.Fields {
if !strings.Contains(out, k) {
t.Errorf("{TypeName} YAML missing key %q", k)
}
if v != "" && !strings.Contains(out, v) {
t.Errorf("{TypeName} YAML missing value %q", v)
}
}
}
}
// 2. FrameTitle — "yaml" in title
func TestQA_YAML_{TypeName}_FrameTitle(t *testing.T) {
items := fixture{TypeName}()
m := yamlModel(items[0], 120, 40)
title := m.FrameTitle()
if !strings.Contains(title, "yaml") {
t.Errorf("FrameTitle() = %q, want 'yaml' in title", title)
}
}
// 3. RawContentUncolored — no ANSI escape codes in plain content
func TestQA_YAML_{TypeName}_RawContentUncolored(t *testing.T) {
items := fixture{TypeName}()
m := yamlModel(items[0], 120, 40)
raw := m.RawContent()
if strings.Contains(raw, "\x1b[") {
t.Error("{TypeName} RawContent() contains ANSI codes")
}
}
Append point: After the last TestQA_YAML_* function in qa_yaml_v220_test.go (currently TestQA_YAML_Backup_RawContentUncolored).
tests/unit/qa_list_rawstruct_test.go (APPEND — package unit_test)Pattern file: tests/unit/qa_list_rawstruct_test.go (see TestQA_ListRawStruct_EC2)
Helper: newListModel() from the same file
Add 1 test per resource type (skip types without SDK structs):
func TestQA_ListRawStruct_{TypeName}(t *testing.T) {
ensureNoColor(t)
cfg := configForType("{shortname}")
raw := realistic{TypeName}() // from step 11's builder
res := resource.Resource{
ID: "test-id", Name: "test-name", Status: "active",
Fields: map[string]string{
"key1": "value1", // matching types_{category}.go column keys
},
RawStruct: raw,
}
view := newListModel(t, "{shortname}", cfg, []resource.Resource{res})
// Verify SDK struct field values appear in list output
if !strings.Contains(view, "value1") {
t.Errorf("{TypeName} list should contain value1 from RawStruct, got:\n%s", view)
}
}
Append point: After the last TestQA_ListRawStruct_* function in qa_list_rawstruct_test.go.
Note: The realistic{TypeName}() function from step 11 is reused here. Both files are in package unit_test, so they share builders.
Coverage analysis found these gaps when steps 11-13 were missing:
fieldpath.ExtractSubtreedefaults_{category}.go might reference non-existent struct fieldsgo run ./cmd/viewsgen/go run ./cmd/refgen/ > .a9s/views_reference.yamlmake testmake lintmake gofixmake build