This guide explains how to add support for new AWS resources to claws.
Adding a new resource requires:
- Creating DAO (data access) implementation
- Creating Renderer (UI) implementation
- Registering in the registry
- Optionally adding actions in Go code
Create a new directory in custom/<service>/<resource>/:
custom/<service>/<resource>/
├── dao.go # Data access + Resource type
├── render.go # UI rendering
├── register.go # Registry registration
└── actions.go # (optional) Action executors
dao.go:
package myresource
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go-v2/service/myservice"
appaws "github.com/claws/claws/internal/aws"
"github.com/claws/claws/internal/dao"
)
// DAO implementation
type MyResourceDAO struct {
dao.BaseDAO
client *myservice.Client
}
func NewMyResourceDAO(ctx context.Context) (dao.DAO, error) {
cfg, err := appaws.NewConfig(ctx)
if err != nil {
return nil, err
}
return &MyResourceDAO{
BaseDAO: dao.NewBaseDAO("myservice", "myresources"),
client: myservice.NewFromConfig(cfg),
}, nil
}
func (d *MyResourceDAO) List(ctx context.Context) ([]dao.Resource, error) {
// Check for context filter (for sub-resources or filtered navigation)
if filter := dao.GetFilterFromContext(ctx, "ParentId"); filter != "" {
// Filter by parent
}
// Use appaws.Paginate for automatic pagination
items, err := appaws.Paginate(ctx, func(token *string) ([]myservice.Item, *string, error) {
output, err := d.client.ListItems(ctx, &myservice.ListItemsInput{
NextToken: token,
})
if err != nil {
return nil, nil, fmt.Errorf("list items: %w", err)
}
return output.Items, output.NextToken, nil
})
if err != nil {
return nil, err
}
resources := make([]dao.Resource, len(items))
for i, item := range items {
resources[i] = NewMyResource(item)
}
return resources, nil
}
func (d *MyResourceDAO) Get(ctx context.Context, id string) (dao.Resource, error) {
output, err := d.client.DescribeItem(ctx, &myservice.DescribeItemInput{
ItemId: &id,
})
if err != nil {
if appaws.IsNotFound(err) {
return nil, dao.ErrNotFound
}
return nil, fmt.Errorf("describe item: %w", err)
}
return NewMyResourceWithDetail(output.Item, output), nil
}
func (d *MyResourceDAO) Delete(ctx context.Context, id string) error {
_, err := d.client.DeleteItem(ctx, &myservice.DeleteItemInput{
ItemId: &id,
})
if err != nil {
if appaws.IsNotFound(err) {
return nil
}
return fmt.Errorf("delete item: %w", err)
}
return nil
}
// Resource type
type MyResource struct {
dao.BaseResource
Item myservice.Item
Detail *myservice.DescribeOutput // optional, for Get()
}
func NewMyResource(item myservice.Item) *MyResource {
return &MyResource{
BaseResource: dao.BaseResource{
ID: appaws.Str(item.Id),
Name: appaws.Str(item.Name),
ARN: appaws.Str(item.Arn),
Data: item,
},
Item: item,
}
}
func NewMyResourceWithDetail(item myservice.Item, detail *myservice.DescribeOutput) *MyResource {
r := NewMyResource(item)
r.Detail = detail
return r
}
// Helper methods for renderer
func (r *MyResource) Status() string {
return string(r.Item.Status)
}
func (r *MyResource) CreatedAt() string {
return render.FormatAge(appaws.Time(r.Item.CreatedAt))
}render.go:
package myresource
import (
"github.com/claws/claws/internal/dao"
"github.com/claws/claws/internal/render"
)
// Ensure interface compliance
var _ render.Navigator = (*MyResourceRenderer)(nil)
type MyResourceRenderer struct {
render.BaseRenderer
}
func NewMyResourceRenderer() render.Renderer {
return &MyResourceRenderer{
BaseRenderer: render.BaseRenderer{
Service: "myservice",
Resource: "myresources",
Cols: []render.Column{
{
Name: "NAME",
Width: 30,
Getter: func(r dao.Resource) string {
return r.GetName()
},
Priority: 0,
},
{
Name: "STATUS",
Width: 12,
Getter: func(r dao.Resource) string {
if mr, ok := r.(*MyResource); ok {
return mr.Status()
}
return ""
},
Priority: 1,
},
{
Name: "AGE",
Width: 10,
Getter: func(r dao.Resource) string {
if mr, ok := r.(*MyResource); ok {
return mr.CreatedAt()
}
return ""
},
Priority: 2,
},
},
},
}
}
func (r *MyResourceRenderer) RenderDetail(resource dao.Resource) string {
mr, ok := resource.(*MyResource)
if !ok {
return ""
}
d := render.NewDetailBuilder()
d.Title("My Resource", mr.GetName())
d.Section("Basic Information")
d.Field("ID", mr.GetID())
d.Field("Name", mr.GetName())
d.Field("ARN", mr.GetARN())
d.Field("Status", mr.Status())
d.Field("Created", mr.CreatedAt())
// Add more sections as needed
if mr.Detail != nil {
d.Section("Configuration")
if s := appaws.Str(mr.Detail.Setting1); s != "" {
d.Field("Setting1", s)
} else {
d.Field("Setting1", render.NotConfigured)
}
d.Field("Setting2", appaws.Str(mr.Detail.Setting2))
}
// For complex objects, use JSON display
d.Section("Full Details")
d.JSON(mr.Item)
return d.String()
}
func (r *MyResourceRenderer) RenderSummary(resource dao.Resource) []render.SummaryField {
mr, ok := resource.(*MyResource)
if !ok {
return nil
}
return []render.SummaryField{
{Label: "Name", Value: mr.GetName()},
{Label: "Status", Value: mr.Status()},
{Label: "Created", Value: mr.CreatedAt()},
}
}
// IMPORTANT: Method signature must take resource dao.Resource parameter
func (r *MyResourceRenderer) Navigations(resource dao.Resource) []render.Navigation {
mr, ok := resource.(*MyResource)
if !ok {
return nil
}
return []render.Navigation{
{
Key: "c",
Label: "Children",
Service: "myservice",
Resource: "children",
FilterField: "ParentId", // Field name for DAO filter
FilterValue: mr.GetID(), // Actual value from resource
},
{
Key: "l",
Label: "Logs",
Service: "cloudwatch",
Resource: "log-groups",
FilterField: "LogGroupName",
FilterValue: "/aws/myservice/" + mr.GetName(),
},
}
}register.go:
package myresource
import (
"context"
"github.com/claws/claws/internal/dao"
"github.com/claws/claws/internal/registry"
"github.com/claws/claws/internal/render"
)
func init() {
registry.Global.RegisterCustom("myservice", "myresources", registry.Entry{
DAOFactory: func(ctx context.Context) (dao.DAO, error) {
return NewMyResourceDAO(ctx)
},
RendererFactory: func() render.Renderer {
return NewMyResourceRenderer()
},
})
}Run the import generator to include your new resource:
task gen-importsThis automatically scans custom/**/register.go and regenerates cmd/claws/imports_custom.go.
Note: The imports file is auto-generated. Never edit
cmd/claws/imports_custom.gomanually.
Create actions.go in the same directory:
package myresource
import (
"context"
"fmt"
"github.com/claws/claws/internal/action"
appaws "github.com/claws/claws/internal/aws"
"github.com/claws/claws/internal/dao"
)
func init() {
action.RegisterExecutor("myservice", "myresources", ExecuteAction)
}
func ExecuteAction(ctx context.Context, act action.Action, resource dao.Resource) error {
mr := resource.(*MyResource)
cfg, err := appaws.NewConfig(ctx)
if err != nil {
return err
}
client := myservice.NewFromConfig(cfg)
switch act.Name {
case "Delete":
_, err := client.DeleteItem(ctx, &myservice.DeleteItemInput{
ItemId: &mr.ID,
})
if err != nil {
return fmt.Errorf("delete item: %w", err)
}
}
return nil
}For resources that may return thousands of items (e.g., CloudTrail events), implement PaginatedDAO:
type MyResourceDAO struct {
dao.BaseDAO
client *myservice.Client
}
// Regular List for standard usage
func (d *MyResourceDAO) List(ctx context.Context) ([]dao.Resource, error) {
// Return first page only with reasonable limit
return d.ListPage(ctx, 100, "")
}
// ListPage for manual pagination with 'N' key
func (d *MyResourceDAO) ListPage(ctx context.Context, pageSize int, pageToken string) ([]dao.Resource, string, error) {
input := &myservice.ListItemsInput{
MaxResults: aws.Int32(int32(pageSize)),
}
if pageToken != "" {
input.NextToken: aws.String(pageToken),
}
output, err := d.client.ListItems(ctx, input)
if err != nil {
return nil, "", fmt.Errorf("list items: %w", err)
}
resources := make([]dao.Resource, len(output.Items))
for i, item := range output.Items {
resources[i] = NewMyResource(item)
}
nextToken := ""
if output.NextToken != nil {
nextToken = *output.NextToken
}
return resources, nextToken, nil
}For resources that are only accessible via navigation (e.g., require parent context):
- Add to
isSubResource()ininternal/registry/registry.go:
func isSubResource(resource string) bool {
subResources := []string{
"events", "outputs", "resources", "log-streams", "quotas",
"mychildren", // Add your sub-resource here
}
return slices.Contains(subResources, resource)
}- Use context filtering in DAO:
func (d *ChildDAO) List(ctx context.Context) ([]dao.Resource, error) {
parentId := dao.GetFilterFromContext(ctx, "ParentId")
if parentId == "" {
return nil, fmt.Errorf("ParentId filter required")
}
// List children for parent
}-
Use
BaseDAO- Embeddao.BaseDAOfor defaultServiceName()andResourceType()implementations. -
Use
BaseRenderer- Embedrender.BaseRendererfor defaultRenderRow()and column handling. -
Use
DetailBuilder- Userender.NewDetailBuilder()for consistent detail views. -
Use Empty Value Constants - For detail views, use these constants instead of hardcoded strings:
render.NotConfigured // "Not configured" - for optional features not set up render.Empty // "None" - for empty lists/collections render.NoValue // "-" - for missing single values
These are automatically replaced with "Loading..." during async detail refresh.
-
Use AWS Helpers - Always use
appaws.Str(),appaws.Int32(), etc. for safe pointer dereferencing. -
Use
appaws.Paginate- For List methods, use pagination helper to collect all results. -
Filter by Name, Not ARN - When setting up navigation filters, prefer using names over ARNs for reliability. ARNs can cause issues with client-side filtering.
-
Sort Results - If listing from multiple sources, sort results for consistent ordering.
-
Error Handling - Use
appaws.IsNotFound()to check for "not found" errors. -
Test Locally:
task build && ./claws # Navigate to :myservice/myresources
-
Check Dead Code:
go run golang.org/x/tools/cmd/deadcode@latest ./...
When implementing navigation between resources:
-
Renderer
Navigations(resource)method:- Must return
[]render.NavigationwithFilterFieldandFilterValueset FilterValuemust be dynamically extracted from the current resource
- Must return
-
Target DAO
List(ctx)method:- Must call
dao.GetFilterFromContext(ctx, "FilterFieldName")to get the filter value - Must use the filter value in the API call
- Must call
-
Wrong method signature:
- Wrong:
func (r *Renderer) GetNavigations() []render.Navigation - Correct:
func (r *Renderer) Navigations(resource dao.Resource) []render.Navigation
- Wrong:
-
Static FilterValue:
- Wrong: Only setting
FilterFieldwithoutFilterValue - Correct: Extract value from resource and set both
- Wrong: Only setting
-
DAO ignoring filter:
- The DAO's
Listmethod MUST check for filters usingdao.GetFilterFromContext()
- The DAO's
When you run task gen-imports, the following files are auto-generated:
Blank imports that trigger init() registration for all resources.
Contains the ServiceResourcePath constant used for consistent error messages:
// Code generated by go generate; DO NOT EDIT.
// To regenerate: task gen-imports
package myresource
// ServiceResourcePath is the canonical path for this resource type.
const ServiceResourcePath = "myservice/myresources"Usage in DAO:
func NewMyResourceDAO(ctx context.Context) (dao.DAO, error) {
cfg, err := appaws.NewConfig(ctx)
if err != nil {
return nil, apperrors.Wrap(err, "new "+ServiceResourcePath+" dao")
}
// ...
}
⚠️ Never edit generated files manually. Always usetask gen-importsafter adding new resources.
The CI pipeline verifies that generated files are up-to-date:
- name: Check generated files
run: |
go generate ./...
git diff --exit-code || (echo "Generated files are out of date. Run 'task gen-imports' and commit." && exit 1)If CI fails with this check, run task gen-imports locally and commit the changes.