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
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,92 @@ A standardized protocol for connecting AI agents to external data sources and to

### Skills
Pre-configured capabilities or specialized functions that can be enabled for an agent. Skills extend what an agent can do, such as code review, testing, or specific domain knowledge.

### Workspace
A registered directory containing your project source code and its configuration. Each workspace is tracked by kortex-cli with a unique ID and name for easy management.

## Commands

### `init` - Register a New Workspace

Registers a new workspace with kortex-cli, making it available for agent launch and configuration.

#### Usage

```bash
kortex-cli init [sources-directory] [flags]
```

#### Arguments

- `sources-directory` - Path to the directory containing your project source files (optional, defaults to current directory `.`)

#### Flags

- `--workspace-configuration <path>` - Directory for workspace configuration files (default: `<sources-directory>/.kortex`)
- `--name, -n <name>` - Human-readable name for the workspace (default: generated from sources directory)
- `--verbose, -v` - Show detailed output including all workspace information
- `--storage <path>` - Storage directory for kortex-cli data (default: `$HOME/.kortex-cli`)

#### Examples

**Register the current directory:**
```bash
kortex-cli init
```
Output: `a1b2c3d4e5f6...` (workspace ID)

**Register a specific directory:**
```bash
kortex-cli init /path/to/myproject
```

**Register with a custom name:**
```bash
kortex-cli init /path/to/myproject --name "my-awesome-project"
```

**Register with custom configuration location:**
```bash
kortex-cli init /path/to/myproject --workspace-configuration /path/to/config
```

**View detailed output:**
```bash
kortex-cli init --verbose
```
Output:
```
Registered workspace:
ID: a1b2c3d4e5f6...
Name: myproject
Sources directory: /absolute/path/to/myproject
Configuration directory: /absolute/path/to/myproject/.kortex
```

#### Workspace Naming

- If `--name` is not provided, the name is automatically generated from the last component of the sources directory path
- If a workspace with the same name already exists, kortex-cli automatically appends an increment (`-2`, `-3`, etc.) to ensure uniqueness

**Examples:**
```bash
# First workspace in /home/user/project
kortex-cli init /home/user/project
# Name: "project"

# Second workspace with the same directory name
kortex-cli init /home/user/another-location/project --name "project"
# Name: "project-2"

# Third workspace with the same name
kortex-cli init /tmp/project --name "project"
# Name: "project-3"
```

#### Notes

- All directory paths are converted to absolute paths for consistency
- The workspace ID is a unique identifier generated automatically
- Workspaces can be listed using the `workspace list` command
- The default configuration directory (`.kortex`) is created inside the sources directory unless specified otherwise
11 changes: 10 additions & 1 deletion pkg/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
type initCmd struct {
sourcesDir string
workspaceConfigDir string
name string
absSourcesDir string
absConfigDir string
manager instances.Manager
Expand Down Expand Up @@ -82,7 +83,11 @@ func (i *initCmd) preRun(cmd *cobra.Command, args []string) error {
// 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)
instance, err := instances.NewInstance(instances.NewInstanceParams{
SourceDir: i.absSourcesDir,
ConfigDir: i.absConfigDir,
Name: i.name,
})
if err != nil {
return fmt.Errorf("failed to create instance: %w", err)
}
Expand All @@ -96,6 +101,7 @@ func (i *initCmd) run(cmd *cobra.Command, args []string) error {
if i.verbose {
cmd.Printf("Registered workspace:\n")
cmd.Printf(" ID: %s\n", addedInstance.GetID())
cmd.Printf(" Name: %s\n", addedInstance.GetName())
cmd.Printf(" Sources directory: %s\n", addedInstance.GetSourceDir())
cmd.Printf(" Configuration directory: %s\n", addedInstance.GetConfigDir())
} else {
Expand Down Expand Up @@ -123,6 +129,9 @@ The workspace configuration directory defaults to .kortex/ inside the sources di
// Add workspace-configuration flag
cmd.Flags().StringVar(&c.workspaceConfigDir, "workspace-configuration", "", "Directory for workspace configuration (default: <sources-directory>/.kortex)")

// Add name flag
cmd.Flags().StringVarP(&c.name, "name", "n", "", "Name for the workspace (default: generated from sources directory)")

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

Expand Down
187 changes: 187 additions & 0 deletions pkg/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ func TestInitCmd_E2E(t *testing.T) {
t.Error("Expected instance to have a non-empty ID")
}

// Verify instance has a non-empty Name
if inst.GetName() == "" {
t.Error("Expected instance to have a non-empty Name")
}

// Verify output contains only the ID (default non-verbose output)
output := strings.TrimSpace(buf.String())
if output != inst.GetID() {
Expand Down Expand Up @@ -651,4 +656,186 @@ func TestInitCmd_E2E(t *testing.T) {
t.Errorf("Expected verbose output to contain instance ID %s, got: %s", inst.GetID(), output)
}
})

t.Run("generates default name from source directory", func(t *testing.T) {
t.Parallel()

storageDir := t.TempDir()
sourcesDir := t.TempDir()

rootCmd := NewRootCmd()
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetArgs([]string{"--storage", storageDir, "init", sourcesDir})

err := rootCmd.Execute()
if err != nil {
t.Fatalf("Execute() failed: %v", err)
}

// Verify instance name is generated from source directory
manager, err := instances.NewManager(storageDir)
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}

instancesList, err := manager.List()
if err != nil {
t.Fatalf("Failed to list instances: %v", err)
}

if len(instancesList) != 1 {
t.Fatalf("Expected 1 instance, got %d", len(instancesList))
}

inst := instancesList[0]
expectedName := filepath.Base(sourcesDir)

if inst.GetName() != expectedName {
t.Errorf("Expected name %s, got %s", expectedName, inst.GetName())
}
})

t.Run("uses custom name from flag", func(t *testing.T) {
t.Parallel()

storageDir := t.TempDir()
sourcesDir := t.TempDir()
customName := "my-workspace"

rootCmd := NewRootCmd()
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetArgs([]string{"--storage", storageDir, "init", sourcesDir, "--name", customName})

err := rootCmd.Execute()
if err != nil {
t.Fatalf("Execute() failed: %v", err)
}

// Verify instance name is the custom name
manager, err := instances.NewManager(storageDir)
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}

instancesList, err := manager.List()
if err != nil {
t.Fatalf("Failed to list instances: %v", err)
}

if len(instancesList) != 1 {
t.Fatalf("Expected 1 instance, got %d", len(instancesList))
}

inst := instancesList[0]

if inst.GetName() != customName {
t.Errorf("Expected name %s, got %s", customName, inst.GetName())
}
})

t.Run("generates unique names with increments", func(t *testing.T) {
t.Parallel()

storageDir := t.TempDir()
// Create three temp directories with the same base name pattern
parentDir := t.TempDir()
sourcesDir1 := filepath.Join(parentDir, "project")
sourcesDir2 := filepath.Join(parentDir, "project-other")
sourcesDir3 := filepath.Join(parentDir, "project-another")

// Register first workspace with name "project"
rootCmd1 := NewRootCmd()
buf1 := new(bytes.Buffer)
rootCmd1.SetOut(buf1)
rootCmd1.SetArgs([]string{"--storage", storageDir, "init", sourcesDir1})

err := rootCmd1.Execute()
if err != nil {
t.Fatalf("Execute() failed for first workspace: %v", err)
}

// Register second workspace with the same name "project" (should become "project-2")
rootCmd2 := NewRootCmd()
buf2 := new(bytes.Buffer)
rootCmd2.SetOut(buf2)
rootCmd2.SetArgs([]string{"--storage", storageDir, "init", sourcesDir2, "--name", "project"})

err = rootCmd2.Execute()
if err != nil {
t.Fatalf("Execute() failed for second workspace: %v", err)
}

// Register third workspace with the same name "project" (should become "project-3")
rootCmd3 := NewRootCmd()
buf3 := new(bytes.Buffer)
rootCmd3.SetOut(buf3)
rootCmd3.SetArgs([]string{"--storage", storageDir, "init", sourcesDir3, "--name", "project"})

err = rootCmd3.Execute()
if err != nil {
t.Fatalf("Execute() failed for third workspace: %v", err)
}

// Verify all three instances have unique names
manager, err := instances.NewManager(storageDir)
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}

instancesList, err := manager.List()
if err != nil {
t.Fatalf("Failed to list instances: %v", err)
}

if len(instancesList) != 3 {
t.Fatalf("Expected 3 instances, got %d", len(instancesList))
}

// Verify names are unique
names := make(map[string]bool)
for _, inst := range instancesList {
if names[inst.GetName()] {
t.Errorf("Duplicate name found: %s", inst.GetName())
}
names[inst.GetName()] = true
}

// Verify expected names are present
expectedNames := []string{"project", "project-2", "project-3"}
for _, expectedName := range expectedNames {
if !names[expectedName] {
t.Errorf("Expected name %s not found in instances", expectedName)
}
}
})

t.Run("verbose output includes name", func(t *testing.T) {
t.Parallel()

storageDir := t.TempDir()
sourcesDir := t.TempDir()
customName := "my-workspace"

rootCmd := NewRootCmd()
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetArgs([]string{"--storage", storageDir, "init", sourcesDir, "--name", customName, "--verbose"})

err := rootCmd.Execute()
if err != nil {
t.Fatalf("Execute() failed: %v", err)
}

output := buf.String()

// Verify verbose output contains the name
if !strings.Contains(output, "Name:") {
t.Errorf("Expected verbose output to contain 'Name:', got: %s", output)
}
if !strings.Contains(output, customName) {
t.Errorf("Expected verbose output to contain name %s, got: %s", customName, output)
}
})
}
20 changes: 16 additions & 4 deletions pkg/cmd/workspace_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ func TestWorkspaceListCmd_E2E(t *testing.T) {
t.Fatalf("Failed to create manager: %v", err)
}

instance, err := instances.NewInstance(sourcesDir, filepath.Join(sourcesDir, ".kortex"))
instance, err := instances.NewInstance(instances.NewInstanceParams{
SourceDir: sourcesDir,
ConfigDir: filepath.Join(sourcesDir, ".kortex"),
})
if err != nil {
t.Fatalf("Failed to create instance: %v", err)
}
Expand Down Expand Up @@ -138,12 +141,18 @@ func TestWorkspaceListCmd_E2E(t *testing.T) {
t.Fatalf("Failed to create manager: %v", err)
}

instance1, err := instances.NewInstance(sourcesDir1, filepath.Join(sourcesDir1, ".kortex"))
instance1, err := instances.NewInstance(instances.NewInstanceParams{
SourceDir: sourcesDir1,
ConfigDir: filepath.Join(sourcesDir1, ".kortex"),
})
if err != nil {
t.Fatalf("Failed to create instance 1: %v", err)
}

instance2, err := instances.NewInstance(sourcesDir2, filepath.Join(sourcesDir2, ".kortex"))
instance2, err := instances.NewInstance(instances.NewInstanceParams{
SourceDir: sourcesDir2,
ConfigDir: filepath.Join(sourcesDir2, ".kortex"),
})
if err != nil {
t.Fatalf("Failed to create instance 2: %v", err)
}
Expand Down Expand Up @@ -197,7 +206,10 @@ func TestWorkspaceListCmd_E2E(t *testing.T) {
t.Fatalf("Failed to create manager: %v", err)
}

instance, err := instances.NewInstance(sourcesDir, filepath.Join(sourcesDir, ".kortex"))
instance, err := instances.NewInstance(instances.NewInstanceParams{
SourceDir: sourcesDir,
ConfigDir: filepath.Join(sourcesDir, ".kortex"),
})
if err != nil {
t.Fatalf("Failed to create instance: %v", err)
}
Expand Down
Loading