diff --git a/cmd/account_id.go b/cmd/account_id.go index 239d5bc..eca27b5 100644 --- a/cmd/account_id.go +++ b/cmd/account_id.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -11,7 +12,10 @@ import ( // prefers an explicit --account-id flag, falls back to the configured // profile via viper, and returns an error if neither is set. func resolveAccountID(cmd *cobra.Command) (string, error) { - accountID, _ := cmd.Flags().GetString("account-id") + accountID, err := cmd.Flags().GetString("account-id") + if err != nil { + accountID = "" + } if accountID == "" { accountID = viper.GetString("account-id") } @@ -20,3 +24,23 @@ func resolveAccountID(cmd *cobra.Command) (string, error) { } return accountID, nil } + +// resolveAccountIDWithPositional returns the account-id prioritizing an explicit +// --account-id flag (if set), followed by a positional argument, and falling +// back to profile config via viper. +func resolveAccountIDWithPositional(cmd *cobra.Command, args []string) (string, error) { + if cmd.Flags().Changed("account-id") { + flagVal, err := cmd.Flags().GetString("account-id") + if err == nil && strings.TrimSpace(flagVal) != "" { + return strings.TrimSpace(flagVal), nil + } + } + if len(args) > 0 && strings.TrimSpace(args[0]) != "" { + return strings.TrimSpace(args[0]), nil + } + accountID, err := resolveAccountID(cmd) + if err != nil { + return "", fmt.Errorf("resolving account ID: %w", err) + } + return accountID, nil +} diff --git a/cmd/nubi_kb.go b/cmd/nubi_kb.go new file mode 100644 index 0000000..a1f742b --- /dev/null +++ b/cmd/nubi_kb.go @@ -0,0 +1,361 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/nudgebee/nbctl/pkg/client" + "github.com/nudgebee/nbctl/pkg/format" + "github.com/spf13/cobra" +) + +var nubiKbCmd = &cobra.Command{ + Use: "kb", + Short: "Manage Knowledge Base vector sources for AI retrieval", +} + +type kbItem struct { + ID string `json:"id"` + Name string `json:"name"` + KBType string `json:"kb_type"` + KBSource string `json:"kb_source"` + Status string `json:"status"` + DocumentCount int `json:"document_count"` + DataSizeBytes float64 `json:"data_size_bytes"` + LastLoadedAt string `json:"last_loaded_at"` + UpdatedAt string `json:"updated_at"` +} + +type graphqlErrorItem struct { + Message string `json:"message"` +} + +func joinGraphQLErrors(errs []graphqlErrorItem) error { + if len(errs) == 0 { + return nil + } + var msgs []string + for _, e := range errs { + if strings.TrimSpace(e.Message) != "" { + msgs = append(msgs, strings.TrimSpace(e.Message)) + } + } + if len(msgs) == 0 { + return nil + } + return fmt.Errorf("backend error: %s", strings.Join(msgs, "; ")) +} + +var nubiKbListCmd = &cobra.Command{ + Use: "list [account-id]", + Short: "List Knowledge Base sources", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + accountID, err := resolveAccountIDWithPositional(cmd, args) + if err != nil { + return err + } + + req := client.NewRequest(` + query ListKB($request: ListKBRequest!) { + ai_list_kb(request: $request) { + data { + id + name + kb_type + kb_source + status + document_count + data_size_bytes + last_loaded_at + updated_at + } + errors { + message + } + } + } + `) + req.Var("request", map[string]any{ + "account_id": accountID, + }) + + var respData struct { + AiListKb *struct { + Data []kbItem `json:"data"` + Errors []graphqlErrorItem `json:"errors"` + } `json:"ai_list_kb"` + } + + if err := client.Run(cmd.Context(), req, &respData); err != nil { + return err + } + + if respData.AiListKb == nil { + return fmt.Errorf("empty response from backend") + } + + if err := joinGraphQLErrors(respData.AiListKb.Errors); err != nil { + return err + } + + table := format.TabularData{ + Data: respData.AiListKb.Data, + Fields: []format.TableField{ + {Header: "KB ID", Field: "ID"}, + {Header: "Name", Field: "Name"}, + {Header: "KB Type", Field: "KBType"}, + {Header: "Status", Field: "Status"}, + {Header: "Documents", Field: "DocumentCount"}, + {Header: "Last Loaded", Field: "LastLoadedAt"}, + }, + } + format.GetFormat().Print(table) + + return nil + }, +} + +var nubiKbGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get details for a Knowledge Base source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + kbID := strings.TrimSpace(args[0]) + if kbID == "" { + return fmt.Errorf("kb-id cannot be empty") + } + + accountID, err := resolveAccountID(cmd) + if err != nil { + return fmt.Errorf("resolving account ID: %w", err) + } + + req := client.NewRequest(` + query GetKB($request: GetKBRequest!) { + ai_get_kb(request: $request) { + data { + id + tenant_id + account_id + name + description + data_format + data_filename + data_size_bytes + status + kb_type + kb_source + integration_id + document_count + last_loaded_at + created_at + updated_at + } + errors { + message + } + } + } + `) + req.Var("request", map[string]any{ + "account_id": accountID, + "id": kbID, + }) + + var respData struct { + AiGetKb *struct { + Data *struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + AccountID string `json:"account_id"` + Name string `json:"name"` + Description string `json:"description"` + DataFormat string `json:"data_format"` + DataFilename string `json:"data_filename"` + DataSizeBytes float64 `json:"data_size_bytes"` + Status string `json:"status"` + KBType string `json:"kb_type"` + KBSource string `json:"kb_source"` + IntegrationID string `json:"integration_id"` + DocumentCount int `json:"document_count"` + LastLoadedAt string `json:"last_loaded_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } `json:"data"` + Errors []graphqlErrorItem `json:"errors"` + } `json:"ai_get_kb"` + } + + if err := client.Run(cmd.Context(), req, &respData); err != nil { + return err + } + + if respData.AiGetKb == nil { + return fmt.Errorf("empty response from backend") + } + + if err := joinGraphQLErrors(respData.AiGetKb.Errors); err != nil { + return err + } + + if respData.AiGetKb.Data == nil { + return fmt.Errorf("knowledge base '%s' not found", kbID) + } + + format.GetFormat().Print(*respData.AiGetKb.Data) + return nil + }, +} + +type kbActionResponse struct { + KBID string `json:"kb_id"` + Status string `json:"status"` + Message string `json:"message"` +} + +var nubiKbSyncCmd = &cobra.Command{ + Use: "sync ", + Short: "Trigger manual re-indexing / vector embedding sync for a Knowledge Base", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + kbID := strings.TrimSpace(args[0]) + if kbID == "" { + return fmt.Errorf("kb-id cannot be empty") + } + + accountID, err := resolveAccountID(cmd) + if err != nil { + return fmt.Errorf("resolving account ID: %w", err) + } + + req := client.NewRequest(` + mutation SyncKB($request: RetriggerKBRequest!) { + ai_sync_kb(request: $request) { + data + errors { + message + } + } + } + `) + req.Var("request", map[string]any{ + "account_id": accountID, + "id": kbID, + }) + + var respData struct { + AiSyncKb *struct { + Data any `json:"data"` + Errors []graphqlErrorItem `json:"errors"` + } `json:"ai_sync_kb"` + } + + if err := client.Run(cmd.Context(), req, &respData); err != nil { + return err + } + + if respData.AiSyncKb == nil { + return fmt.Errorf("empty response from backend") + } + + if err := joinGraphQLErrors(respData.AiSyncKb.Errors); err != nil { + return err + } + + format.GetFormat().Print(kbActionResponse{ + KBID: kbID, + Status: "triggered", + Message: "Knowledge Base vector re-indexing triggered successfully", + }) + return nil + }, +} + +var nubiKbEnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable a Knowledge Base source for AI retrieval", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return toggleKBEnabled(cmd, args[0], true) + }, +} + +var nubiKbDisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable a Knowledge Base source from AI retrieval", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return toggleKBEnabled(cmd, args[0], false) + }, +} + +func toggleKBEnabled(cmd *cobra.Command, rawKBID string, enabled bool) error { + kbID := strings.TrimSpace(rawKBID) + if kbID == "" { + return fmt.Errorf("kb-id cannot be empty") + } + + accountID, err := resolveAccountID(cmd) + if err != nil { + return fmt.Errorf("resolving account ID: %w", err) + } + + req := client.NewRequest(` + mutation UpdateKBEnabled($request: UpdateKBEnabledRequest!) { + ai_update_kb_enabled(request: $request) { + data + errors { + message + } + } + } + `) + req.Var("request", map[string]any{ + "account_id": accountID, + "kb_id": kbID, + "enabled": enabled, + }) + + var respData struct { + AiUpdateKbEnabled *struct { + Data any `json:"data"` + Errors []graphqlErrorItem `json:"errors"` + } `json:"ai_update_kb_enabled"` + } + + if err := client.Run(cmd.Context(), req, &respData); err != nil { + return err + } + + if respData.AiUpdateKbEnabled == nil { + return fmt.Errorf("empty response from backend") + } + + if err := joinGraphQLErrors(respData.AiUpdateKbEnabled.Errors); err != nil { + return err + } + + statusStr := "enabled" + if !enabled { + statusStr = "disabled" + } + + format.GetFormat().Print(kbActionResponse{ + KBID: kbID, + Status: statusStr, + Message: fmt.Sprintf("Knowledge Base successfully %s", statusStr), + }) + return nil +} + +func init() { + nubiCmd.AddCommand(nubiKbCmd) + nubiKbCmd.PersistentFlags().String("account-id", "", "Account ID (overrides profile)") + + nubiKbCmd.AddCommand(nubiKbListCmd) + nubiKbCmd.AddCommand(nubiKbGetCmd) + nubiKbCmd.AddCommand(nubiKbSyncCmd) + nubiKbCmd.AddCommand(nubiKbEnableCmd) + nubiKbCmd.AddCommand(nubiKbDisableCmd) +} diff --git a/cmd/nubi_kb_test.go b/cmd/nubi_kb_test.go new file mode 100644 index 0000000..a7b60be --- /dev/null +++ b/cmd/nubi_kb_test.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/nudgebee/nbctl/pkg/testutil" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNubiKbCmd_List(t *testing.T) { + resetNubiFlags() + viper.Set("account-id", "test-account-id") + t.Cleanup(resetNubiFlags) + + mockResponse := map[string]any{ + "ai_list_kb": map[string]any{ + "data": []map[string]any{ + { + "id": "kb-101", + "name": "Payment Runbook", + "kb_type": "manual", + "status": "active", + "document_count": 3, + "last_loaded_at": "2026-09-07T12:00:00Z", + "data_size_bytes": 1024, + }, + }, + }, + } + + output, err := testutil.RunWithSimpleGraphQL(mockResponse, nubiCmd, []string{"nubi", "kb", "list"}) + require.NoError(t, err) + + assert.Contains(t, output, "kb-101") + assert.Contains(t, output, "Payment Runbook") + assert.Contains(t, output, "manual") + assert.Contains(t, output, "active") +} + +func TestNubiKbCmd_List_PositionalAccountPrecedence(t *testing.T) { + resetNubiFlags() + viper.Set("account-id", "profile-account-id") + t.Cleanup(resetNubiFlags) + + mockResponse := map[string]any{ + "ai_list_kb": map[string]any{ + "data": []map[string]any{ + { + "id": "kb-override", + "name": "Overridden KB", + }, + }, + }, + } + + output, err := testutil.RunWithSimpleGraphQL(mockResponse, nubiCmd, []string{"nubi", "kb", "list", "override-account-id"}) + require.NoError(t, err) + assert.Contains(t, output, "kb-override") +} + +func TestNubiKbCmd_List_JSON(t *testing.T) { + resetNubiFlags() + viper.Set("account-id", "test-account-id") + t.Cleanup(resetNubiFlags) + + mockResponse := map[string]any{ + "ai_list_kb": map[string]any{ + "data": []map[string]any{ + { + "id": "kb-102", + "name": "K8s Troubleshooting", + "kb_type": "integration", + "status": "active", + "document_count": 15, + }, + }, + }, + } + + output, err := testutil.RunWithSimpleGraphQL(mockResponse, nubiCmd, []string{"nubi", "kb", "list", "-o", "json"}) + require.NoError(t, err) + + var result []map[string]any + err = json.Unmarshal([]byte(output), &result) + require.NoError(t, err) + + require.Len(t, result, 1) + assert.Equal(t, "kb-102", result[0]["id"]) + assert.Equal(t, "K8s Troubleshooting", result[0]["name"]) +} + +func TestNubiKbCmd_Get(t *testing.T) { + resetNubiFlags() + viper.Set("account-id", "test-account-id") + t.Cleanup(resetNubiFlags) + + mockResponse := map[string]any{ + "ai_get_kb": map[string]any{ + "data": map[string]any{ + "id": "kb-101", + "name": "Payment Runbook", + "description": "SOP for payment gateway latency", + "status": "active", + "kb_type": "manual", + "document_count": 3, + }, + }, + } + + output, err := testutil.RunWithSimpleGraphQL(mockResponse, nubiCmd, []string{"nubi", "kb", "get", "kb-101"}) + require.NoError(t, err) + + assert.Contains(t, output, "kb-101") + assert.Contains(t, output, "Payment Runbook") + assert.Contains(t, output, "SOP for payment gateway latency") +} + +func TestNubiKbCmd_Sync(t *testing.T) { + resetNubiFlags() + viper.Set("account-id", "test-account-id") + t.Cleanup(resetNubiFlags) + + mockResponse := map[string]any{ + "ai_sync_kb": map[string]any{ + "data": "ok", + }, + } + + output, err := testutil.RunWithSimpleGraphQL(mockResponse, nubiCmd, []string{"nubi", "kb", "sync", "kb-101"}) + require.NoError(t, err) + + assert.Contains(t, output, "triggered") + assert.Contains(t, output, "kb-101") +} + +func TestNubiKbCmd_Enable_Disable(t *testing.T) { + resetNubiFlags() + viper.Set("account-id", "test-account-id") + t.Cleanup(resetNubiFlags) + + mockResponse := map[string]any{ + "ai_update_kb_enabled": map[string]any{ + "data": "ok", + }, + } + + t.Run("enable", func(t *testing.T) { + output, err := testutil.RunWithSimpleGraphQL(mockResponse, nubiCmd, []string{"nubi", "kb", "enable", "kb-101"}) + require.NoError(t, err) + assert.Contains(t, output, "enabled") + assert.Contains(t, output, "kb-101") + }) + + t.Run("disable", func(t *testing.T) { + output, err := testutil.RunWithSimpleGraphQL(mockResponse, nubiCmd, []string{"nubi", "kb", "disable", "kb-101"}) + require.NoError(t, err) + assert.Contains(t, output, "disabled") + assert.Contains(t, output, "kb-101") + }) +} diff --git a/cmd/nubi_memory.go b/cmd/nubi_memory.go new file mode 100644 index 0000000..4ad97fd --- /dev/null +++ b/cmd/nubi_memory.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/nudgebee/nbctl/pkg/client" + "github.com/nudgebee/nbctl/pkg/format" + "github.com/spf13/cobra" +) + +var nubiMemoryCmd = &cobra.Command{ + Use: "memory", + Aliases: []string{"bcortex", "app-context"}, + Short: "Manage AI-learned operational memory, architecture decisions, and application context", +} + +type memoryItem struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + Content string `json:"content"` + MemoryType string `json:"memory_type"` + CreatedAt string `json:"created_at"` +} + +var nubiMemoryListCmd = &cobra.Command{ + Use: "list [account-id]", + Short: "List AI operational memory items, architecture decisions, and learned patterns", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + accountID, err := resolveAccountIDWithPositional(cmd, args) + if err != nil { + return err + } + + memoryType, _ := cmd.Flags().GetString("type") + queryFilter, _ := cmd.Flags().GetString("query") + limit, _ := cmd.Flags().GetInt("limit") + if limit <= 0 { + return fmt.Errorf("limit must be greater than 0") + } + + memoryType = strings.TrimSpace(memoryType) + queryFilter = strings.TrimSpace(queryFilter) + + req := client.NewRequest(` + query ListAIMemory($request: ListAIMemoryRequest!) { + ai_list_memory(request: $request) { + data { + id + account_id + conversation_id + message_id + content + memory_type + created_at + } + errors { + message + } + } + } + `) + + input := map[string]any{ + "account_id": accountID, + "limit": limit, + } + if memoryType != "" { + input["memory_type"] = memoryType + } + if queryFilter != "" { + input["query"] = queryFilter + } + req.Var("request", input) + + var respData struct { + AiListMemory *struct { + Data []memoryItem `json:"data"` + Errors []graphqlErrorItem `json:"errors"` + } `json:"ai_list_memory"` + } + + if err := client.Run(cmd.Context(), req, &respData); err != nil { + return err + } + + if respData.AiListMemory == nil { + return fmt.Errorf("empty response from backend") + } + + if err := joinGraphQLErrors(respData.AiListMemory.Errors); err != nil { + return err + } + + table := format.TabularData{ + Data: respData.AiListMemory.Data, + Fields: []format.TableField{ + {Header: "Memory ID", Field: "ID"}, + {Header: "Memory Type", Field: "MemoryType"}, + {Header: "Content", Field: "Content"}, + {Header: "Created At", Field: "CreatedAt"}, + }, + } + format.GetFormat().Print(table) + + return nil + }, +} + +func init() { + nubiCmd.AddCommand(nubiMemoryCmd) + nubiMemoryCmd.AddCommand(nubiMemoryListCmd) + + nubiMemoryListCmd.Flags().String("account-id", "", "Account ID (overrides profile)") + nubiMemoryListCmd.Flags().String("type", "", "Filter memory by type (e.g. pattern, decision, architecture)") + nubiMemoryListCmd.Flags().String("query", "", "Filter memory content by search term") + nubiMemoryListCmd.Flags().Int("limit", 50, "Maximum number of memory items to return") +} diff --git a/cmd/nubi_memory_test.go b/cmd/nubi_memory_test.go new file mode 100644 index 0000000..a0fe874 --- /dev/null +++ b/cmd/nubi_memory_test.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/nudgebee/nbctl/pkg/testutil" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNubiMemoryCmd_List(t *testing.T) { + resetNubiFlags() + viper.Set("account-id", "test-account-id") + t.Cleanup(resetNubiFlags) + + mockResponse := map[string]any{ + "ai_list_memory": map[string]any{ + "data": []map[string]any{ + { + "id": "mem-201", + "memory_type": "investigation_result", + "content": "RabbitMQ queue backlog pattern", + "created_at": "2026-09-07T10:00:00Z", + }, + }, + }, + } + + output, err := testutil.RunWithSimpleGraphQL(mockResponse, nubiCmd, []string{"nubi", "memory", "list"}) + require.NoError(t, err) + + assert.Contains(t, output, "mem-201") + assert.Contains(t, output, "investigation_result") + assert.Contains(t, output, "RabbitMQ queue backlog pattern") +} + +func TestNubiMemoryCmd_List_JSON(t *testing.T) { + resetNubiFlags() + viper.Set("account-id", "test-account-id") + t.Cleanup(resetNubiFlags) + + mockResponse := map[string]any{ + "ai_list_memory": map[string]any{ + "data": []map[string]any{ + { + "id": "mem-202", + "memory_type": "architecture_decision", + "content": "Use PostgreSQL for transaction log persistence", + "created_at": "2026-09-07T11:00:00Z", + }, + }, + }, + } + + output, err := testutil.RunWithSimpleGraphQL(mockResponse, nubiCmd, []string{"nubi", "memory", "list", "-o", "json", "--type", "architecture_decision"}) + require.NoError(t, err) + + var result []map[string]any + err = json.Unmarshal([]byte(output), &result) + require.NoError(t, err) + + require.Len(t, result, 1) + assert.Equal(t, "mem-202", result[0]["id"]) + assert.Equal(t, "architecture_decision", result[0]["memory_type"]) +} + +func TestNubiMemoryCmd_List_InvalidLimit(t *testing.T) { + resetNubiFlags() + viper.Set("account-id", "test-account-id") + t.Cleanup(resetNubiFlags) + + _, err := testutil.RunWithSimpleGraphQL(nil, nubiCmd, []string{"nubi", "memory", "list", "--limit", "0"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "limit must be greater than 0") +} diff --git a/cmd/nubi_test.go b/cmd/nubi_test.go index fb376f8..b1827e9 100644 --- a/cmd/nubi_test.go +++ b/cmd/nubi_test.go @@ -189,8 +189,8 @@ func TestNubiCmd_SyncQuery(t *testing.T) { "ai_get_conversation_usage_metrics": map[string]interface{}{ "data": map[string]interface{}{ "conversation": map[string]interface{}{ - "total_cost": 0.001, - "total_input_tokens": 50, + "total_cost": 0.001, + "total_input_tokens": 50, "total_output_tokens": 100, }, }, @@ -252,8 +252,8 @@ func TestNubiCmd_SyncQuery_JSON(t *testing.T) { "ai_get_conversation_usage_metrics": map[string]interface{}{ "data": map[string]interface{}{ "conversation": map[string]interface{}{ - "total_cost": 0.001, - "total_input_tokens": 50, + "total_cost": 0.001, + "total_input_tokens": 50, "total_output_tokens": 100, }, }, @@ -690,8 +690,8 @@ func TestNubiCmd_Stats(t *testing.T) { "ai_get_conversation_usage_metrics": map[string]interface{}{ "data": map[string]interface{}{ "conversation": map[string]interface{}{ - "total_cost": 0.005, - "total_input_tokens": 1200, + "total_cost": 0.005, + "total_input_tokens": 1200, "total_output_tokens": 350, }, }, @@ -717,8 +717,8 @@ func TestNubiCmd_Stats_JSON(t *testing.T) { "data": map[string]interface{}{ "conversation": map[string]interface{}{ "total_cost_usd": 0.0275597, - "total_input_tokens": 57270, - "total_output_tokens": 996, + "total_input_tokens": 57270, + "total_output_tokens": 996, "total_cached_input_tokens": 39857, "total_cache_hit_rate_percentage": 69.59, "model_usage": []map[string]interface{}{ @@ -1024,4 +1024,3 @@ func TestNubiCmd_SyncQuery_SessionIdDiffersFromConversationId(t *testing.T) { assert.NotEmpty(t, pollVars["sessionId"]) assert.Nil(t, pollVars["conversationId"]) } -