Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,63 @@ All modules (packages outside of `cmd/`) MUST follow the interface-based design

**This pattern is MANDATORY for all new modules in `pkg/`.**

### JSON Storage Structure

When designing JSON storage structures for persistent data, use **nested objects with subfields** instead of flat structures with naming conventions.

**Preferred Pattern (nested structure):**
```json
{
"id": "dc610bffa75f21b5b043f98aff12b157fb16fae6c0ac3139c28f85d6defbe017",
"paths": {
"source": "/Users/user/project",
"configuration": "/Users/user/project/.kortex"
}
}
```

**Avoid (flat structure with snake_case or camelCase):**
```json
{
"id": "...",
"source_dir": "/Users/user/project", // Don't use snake_case
"config_dir": "/Users/user/project/.kortex"
}
```

```json
{
"id": "...",
"sourceDir": "/Users/user/project", // Don't use camelCase
"configDir": "/Users/user/project/.kortex"
}
```

**Benefits:**
- **Better organization** - Related fields are grouped together
- **Clarity** - Field relationships are explicit through nesting
- **Extensibility** - Easy to add new subfields without polluting the top level
- **No naming conflicts** - Avoids debates about snake_case vs camelCase
- **Self-documenting** - Structure communicates intent

**Implementation:**
- Create nested structs with `json` tags
- Use lowercase field names in JSON (Go convention for exported fields + json tags)
- Group related fields under descriptive parent keys

**Example:**
```go
type InstancePaths struct {
Source string `json:"source"`
Configuration string `json:"configuration"`
}

type InstanceData struct {
ID string `json:"id"`
Paths InstancePaths `json:"paths"`
}
```

### Skills System
Skills are reusable capabilities that can be discovered and executed by AI agents:
- **Location**: `skills/<skill-name>/SKILL.md`
Expand Down Expand Up @@ -170,6 +227,130 @@ func NewExampleCmd() *cobra.Command {
rootCmd.AddCommand(NewExampleCmd())
```

### Command Implementation Pattern

Commands should follow a consistent structure for maintainability and testability:

1. **Command Struct** - Contains all command state:
- Input values from flags/args
- Computed/validated values
- Dependencies (e.g., manager instances)

2. **preRun Method** - Validates parameters and prepares:
- Parse and validate arguments/flags
- Access global flags (e.g., `--storage`)
- Create dependencies (managers, etc.)
- Convert paths to absolute using `filepath.Abs()`
- Store validated values in struct fields

3. **run Method** - Executes the command logic:
- Use validated values from struct fields
- Perform the actual operation
- Output results to user

**Reference:** See `pkg/cmd/init.go` for a complete implementation of this pattern.

### Testing Pattern for Commands

Commands should have two types of tests following the pattern in `pkg/cmd/init_test.go`:

1. **Unit Tests** - Test the `preRun` method directly:
- Use `t.Run()` for subtests within a parent test function
- Test with different argument/flag combinations
- Verify struct fields are set correctly
- Use `t.TempDir()` for temporary directories (automatic cleanup)

2. **E2E Tests** - Test the full command execution:
- Execute via `rootCmd.Execute()`
- Use real temp directories with `t.TempDir()`
- Verify output messages
- Verify persistence (check storage/database)
- Verify all field values from `manager.List()` or similar
- Test multiple scenarios (default args, custom args, edge cases)

**Reference:** See `pkg/cmd/init_test.go` for complete examples of both `preRun` unit tests (in `TestInitCmd_PreRun`) and E2E tests (in `TestInitCmd_E2E`).

### Working with the Instances Manager

When commands need to interact with workspaces:

```go
// In preRun - create manager from storage flag
storageDir, _ := cmd.Flags().GetString("storage")
manager, err := instances.NewManager(storageDir)
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}

// In run - use manager to add instances
instance, err := instances.NewInstance(sourceDir, configDir)
if err != nil {
return fmt.Errorf("failed to create instance: %w", err)
}

addedInstance, err := manager.Add(instance)
if err != nil {
return fmt.Errorf("failed to add instance: %w", err)
}

// List instances
instancesList, err := manager.List()
if err != nil {
return fmt.Errorf("failed to list instances: %w", err)
}

// Get specific instance
instance, err := manager.Get(id)
if err != nil {
return fmt.Errorf("instance not found: %w", err)
}

// Delete instance
err := manager.Delete(id)
if err != nil {
return fmt.Errorf("failed to delete instance: %w", err)
}
```

### Cross-Platform Path Handling

**IMPORTANT**: All path operations must be cross-platform compatible (Linux, macOS, Windows).

**Rules:**
- Always use `filepath.Join()` for path construction (never hardcode "/" or "\\")
- Convert relative paths to absolute with `filepath.Abs()`
- Never hardcode paths with `~` - use `os.UserHomeDir()` instead
- In tests, use `filepath.Join()` for all path assertions
- Use `t.TempDir()` for temporary directories in tests

**Examples:**

```go
// GOOD: Cross-platform path construction
configDir := filepath.Join(sourceDir, ".kortex")
absPath, err := filepath.Abs(relativePath)

// BAD: Hardcoded separator
configDir := sourceDir + "/.kortex" // Don't do this!

// GOOD: User home directory
homeDir, err := os.UserHomeDir()
defaultPath := filepath.Join(homeDir, ".kortex-cli")

// BAD: Hardcoded tilde
defaultPath := "~/.kortex-cli" // Don't do this!

// GOOD: Test assertions
expectedPath := filepath.Join(".", "relative", "path")
if result != expectedPath {
t.Errorf("Expected %s, got %s", expectedPath, result)
}

// GOOD: Temporary directories in tests
tempDir := t.TempDir() // Automatic cleanup
sourcesDir := t.TempDir()
```

## Copyright Headers

All source files must include Apache License 2.0 copyright headers with Red Hat copyright. Use the `/copyright-headers` skill to add or update headers automatically. The current year is 2026.
Expand Down
130 changes: 130 additions & 0 deletions pkg/cmd/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**********************************************************************
* Copyright (C) 2026 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
**********************************************************************/

package cmd

import (
"fmt"
"path/filepath"

"github.com/kortex-hub/kortex-cli/pkg/instances"
"github.com/spf13/cobra"
)

// initCmd contains the configuration for the init command
type initCmd struct {
sourcesDir string
workspaceConfigDir string
absSourcesDir string
absConfigDir string
manager instances.Manager
verbose bool
}

// preRun validates the parameters and flags
func (i *initCmd) preRun(cmd *cobra.Command, args []string) error {
// Get storage directory from global flag
storageDir, err := cmd.Flags().GetString("storage")
if err != nil {
return fmt.Errorf("failed to read --storage flag: %w", err)
}

// Create manager
manager, err := instances.NewManager(storageDir)
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
i.manager = manager

// Get sources directory (default to current directory)
i.sourcesDir = "."
if len(args) > 0 {
i.sourcesDir = args[0]
}

// Convert to absolute path for clarity
absSourcesDir, err := filepath.Abs(i.sourcesDir)
if err != nil {
return fmt.Errorf("failed to resolve sources directory path: %w", err)
}
i.absSourcesDir = absSourcesDir

// If workspace-configuration flag was not explicitly set, default to .kortex/ inside sources directory
if !cmd.Flags().Changed("workspace-configuration") {
i.workspaceConfigDir = filepath.Join(i.sourcesDir, ".kortex")
}

// Convert workspace config to absolute path
absConfigDir, err := filepath.Abs(i.workspaceConfigDir)
if err != nil {
return fmt.Errorf("failed to resolve workspace configuration directory path: %w", err)
}
i.absConfigDir = absConfigDir

return nil
}

// run executes the init command logic
func (i *initCmd) run(cmd *cobra.Command, args []string) error {
// Create a new instance
instance, err := instances.NewInstance(i.absSourcesDir, i.absConfigDir)
if err != nil {
return fmt.Errorf("failed to create instance: %w", err)
}

// Add the instance to the manager
addedInstance, err := i.manager.Add(instance)
if err != nil {
return fmt.Errorf("failed to add instance: %w", err)
}

if i.verbose {
cmd.Printf("Registered workspace:\n")
cmd.Printf(" ID: %s\n", addedInstance.GetID())
cmd.Printf(" Sources directory: %s\n", addedInstance.GetSourceDir())
cmd.Printf(" Configuration directory: %s\n", addedInstance.GetConfigDir())
} else {
cmd.Println(addedInstance.GetID())
}

return nil
}

func NewInitCmd() *cobra.Command {
c := &initCmd{}

cmd := &cobra.Command{
Use: "init [sources-directory]",
Short: "Register a new workspace",
Long: `Register a new workspace with the specified sources directory and configuration location.

The sources directory defaults to the current directory (.) if not specified.
The workspace configuration directory defaults to .kortex/ inside the sources directory if not specified.`,
Args: cobra.MaximumNArgs(1),
PreRunE: c.preRun,
RunE: c.run,
}

// Add workspace-configuration flag
cmd.Flags().StringVar(&c.workspaceConfigDir, "workspace-configuration", "", "Directory for workspace configuration (default: <sources-directory>/.kortex)")

// Add verbose flag
cmd.Flags().BoolVarP(&c.verbose, "verbose", "v", false, "Show detailed output")

return cmd
}
Loading