diff --git a/Cargo.lock b/Cargo.lock index 7671855..db7a1d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1926,7 +1926,7 @@ dependencies = [ [[package]] name = "operator" -version = "0.1.18" +version = "0.1.19" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 514a01b..8b38014 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "operator" -version = "0.1.18" +version = "0.1.19" edition = "2021" description = "Multi-agent orchestration dashboard for gbqr.us" authors = ["gbqr.us"] diff --git a/VERSION b/VERSION index f8bc4c6..d8a023e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.18 +0.1.19 diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0338308..1f2bd7f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -3,8 +3,10 @@ trigger: none parameters: - name: blobName type: string + default: '' - name: signedBlobName type: string + default: '' variables: - group: signing-secrets @@ -26,15 +28,19 @@ steps: --name "${{ parameters.blobName }}" ` --file "$(Build.StagingDirectory)\unsigned.exe" - - task: AzureCodeSigning@0 + - task: ArtifactSigning@0 displayName: Sign with Trusted Signing inputs: - ConnectedServiceName: trusted-signing-service-connection - AccountEndpoint: $(TrustedSigningEndpoint) - CertificateProfileName: $(TrustedSigningProfile) + AzureTenantID: $(AzureTenantID) + AzureClientID: $(AzureClientID) + AzureClientSecret: $(AzureClientSecret) + Endpoint: https://ncus.codesigning.azure.net + ArtifactSigningAccountName: $(TrustedSigningAccountName) + CertificateProfileName: Operator FilesFolder: $(Build.StagingDirectory) - FilesFolderFilter: unsigned.exe + FilesFolderFilter: exe FileDigest: SHA256 + TimestampRfc3161: http://timestamp.acs.microsoft.com TimestampDigest: SHA256 - task: AzureCLI@2 diff --git a/backstage-server/package.json b/backstage-server/package.json index 8395777..87990ff 100644 --- a/backstage-server/package.json +++ b/backstage-server/package.json @@ -1,6 +1,6 @@ { "name": "operator-backstage", - "version": "0.1.18", + "version": "0.1.19", "author": { "name": "Samuel Volin", "email": "untra.sam@gmail.com", diff --git a/bindings/Config.ts b/bindings/Config.ts index bc1dd3d..1a9b472 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -2,6 +2,7 @@ import type { AgentsConfig } from "./AgentsConfig"; import type { ApiConfig } from "./ApiConfig"; import type { BackstageConfig } from "./BackstageConfig"; +import type { Delegator } from "./Delegator"; import type { GitConfig } from "./GitConfig"; import type { KanbanConfig } from "./KanbanConfig"; import type { LaunchConfig } from "./LaunchConfig"; @@ -33,4 +34,8 @@ kanban: KanbanConfig, /** * Version check configuration for automatic update notifications */ -version_check: VersionCheckConfig, }; +version_check: VersionCheckConfig, +/** + * Agent delegator configurations for autonomous ticket launching + */ +delegators: Array, }; diff --git a/bindings/CreateDelegatorRequest.ts b/bindings/CreateDelegatorRequest.ts new file mode 100644 index 0000000..254e43b --- /dev/null +++ b/bindings/CreateDelegatorRequest.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DelegatorLaunchConfigDto } from "./DelegatorLaunchConfigDto"; + +/** + * Request to create a new delegator + */ +export type CreateDelegatorRequest = { +/** + * Unique name for the delegator + */ +name: string, +/** + * LLM tool name (must match a detected tool) + */ +llm_tool: string, +/** + * Model alias + */ +model: string, +/** + * Optional display name + */ +display_name: string | null, +/** + * Arbitrary model properties + */ +model_properties: { [key in string]?: string }, +/** + * Optional launch configuration + */ +launch_config: DelegatorLaunchConfigDto | null, }; diff --git a/bindings/Delegator.ts b/bindings/Delegator.ts new file mode 100644 index 0000000..5c75950 --- /dev/null +++ b/bindings/Delegator.ts @@ -0,0 +1,34 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DelegatorLaunchConfig } from "./DelegatorLaunchConfig"; + +/** + * Agent delegator configuration for autonomous ticket launching + * + * A delegator is a named {tool, model} pairing with optional launch configuration + * that can be used to launch agents for tickets. + */ +export type Delegator = { +/** + * Unique name for this delegator (e.g., "claude-opus-auto") + */ +name: string, +/** + * LLM tool name (must match a detected tool, e.g., "claude", "codex") + */ +llm_tool: string, +/** + * Model alias (e.g., "opus", "sonnet", "gpt-4o") + */ +model: string, +/** + * Optional display name for UI + */ +display_name: string | null, +/** + * Arbitrary model properties (e.g., reasoning_effort, sandbox) + */ +model_properties: { [key in string]?: string }, +/** + * Optional launch configuration + */ +launch_config: DelegatorLaunchConfig | null, }; diff --git a/bindings/DelegatorLaunchConfig.ts b/bindings/DelegatorLaunchConfig.ts new file mode 100644 index 0000000..545303d --- /dev/null +++ b/bindings/DelegatorLaunchConfig.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Launch configuration for a delegator + */ +export type DelegatorLaunchConfig = { +/** + * Run in YOLO (auto-accept) mode + */ +yolo: boolean, +/** + * Permission mode override + */ +permission_mode: string | null, +/** + * Additional CLI flags + */ +flags: Array, }; diff --git a/bindings/DelegatorLaunchConfigDto.ts b/bindings/DelegatorLaunchConfigDto.ts new file mode 100644 index 0000000..f1ae1a5 --- /dev/null +++ b/bindings/DelegatorLaunchConfigDto.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Launch configuration DTO for delegators + */ +export type DelegatorLaunchConfigDto = { +/** + * Run in YOLO mode + */ +yolo: boolean, +/** + * Permission mode override + */ +permission_mode: string | null, +/** + * Additional CLI flags + */ +flags: Array, }; diff --git a/bindings/DelegatorResponse.ts b/bindings/DelegatorResponse.ts new file mode 100644 index 0000000..4a10788 --- /dev/null +++ b/bindings/DelegatorResponse.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DelegatorLaunchConfigDto } from "./DelegatorLaunchConfigDto"; + +/** + * Response for a single delegator + */ +export type DelegatorResponse = { +/** + * Unique name + */ +name: string, +/** + * LLM tool name (e.g., "claude") + */ +llm_tool: string, +/** + * Model alias (e.g., "opus") + */ +model: string, +/** + * Optional display name + */ +display_name: string | null, +/** + * Arbitrary model properties + */ +model_properties: { [key in string]?: string }, +/** + * Optional launch configuration + */ +launch_config: DelegatorLaunchConfigDto | null, }; diff --git a/bindings/DelegatorsResponse.ts b/bindings/DelegatorsResponse.ts new file mode 100644 index 0000000..60e8bdf --- /dev/null +++ b/bindings/DelegatorsResponse.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DelegatorResponse } from "./DelegatorResponse"; + +/** + * Response listing all delegators + */ +export type DelegatorsResponse = { +/** + * List of delegators + */ +delegators: Array, +/** + * Total count + */ +total: number, }; diff --git a/bindings/LlmToolsConfig.ts b/bindings/LlmToolsConfig.ts index 8e8a5db..6c375aa 100644 --- a/bindings/LlmToolsConfig.ts +++ b/bindings/LlmToolsConfig.ts @@ -1,6 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DetectedTool } from "./DetectedTool"; import type { LlmProvider } from "./LlmProvider"; +import type { SkillDirectoriesOverride } from "./SkillDirectoriesOverride"; /** * LLM CLI tools configuration @@ -18,4 +19,8 @@ providers: Array, /** * Whether detection has been completed */ -detection_complete: boolean, }; +detection_complete: boolean, +/** + * Per-tool overrides for skill directories (keyed by tool_name) + */ +skill_directory_overrides: { [key in string]?: SkillDirectoriesOverride }, }; diff --git a/bindings/SkillDirectoriesOverride.ts b/bindings/SkillDirectoriesOverride.ts new file mode 100644 index 0000000..e54065c --- /dev/null +++ b/bindings/SkillDirectoriesOverride.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Per-tool skill directory overrides + */ +export type SkillDirectoriesOverride = { +/** + * Additional global skill directories + */ +global: Array, +/** + * Additional project-relative skill directories + */ +project: Array, }; diff --git a/bindings/SkillEntry.ts b/bindings/SkillEntry.ts new file mode 100644 index 0000000..c170b77 --- /dev/null +++ b/bindings/SkillEntry.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A single discovered skill file + */ +export type SkillEntry = { +/** + * Tool this skill belongs to (e.g., "claude", "codex") + */ +tool_name: string, +/** + * Filename of the skill (e.g., "commit.md") + */ +filename: string, +/** + * Full path to the skill file + */ +file_path: string, +/** + * Scope: "global" or "project" + */ +scope: string, }; diff --git a/bindings/SkillsResponse.ts b/bindings/SkillsResponse.ts new file mode 100644 index 0000000..6e88d4d --- /dev/null +++ b/bindings/SkillsResponse.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SkillEntry } from "./SkillEntry"; + +/** + * Response for skills listing + */ +export type SkillsResponse = { +/** + * List of discovered skills + */ +skills: Array, +/** + * Total count + */ +total: number, }; diff --git a/config/default.toml b/config/default.toml index c49fbc3..37a7d93 100644 --- a/config/default.toml +++ b/config/default.toml @@ -106,6 +106,18 @@ webhook_port = 7009 # Connection timeout in milliseconds connect_timeout_ms = 5000 +# Agent delegator configurations +# Delegators are named {tool, model} pairings for autonomous ticket launching +# [[delegators]] +# name = "claude-opus-auto" +# llm_tool = "claude" +# model = "opus" +# display_name = "Claude Opus (Auto)" +# [delegators.launch_config] +# yolo = true +# permission_mode = "delegate" +# flags = [] + [version_check] # Enable automatic version checking on startup enabled = true diff --git a/docs/_config.yml b/docs/_config.yml index 0e970e4..ed6cf4c 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -42,7 +42,7 @@ collections_dir: . # Permalink structure permalink: pretty -version: 0.1.18 +version: 0.1.19 # Google Analytics ga_tag: G-5JZPJWWT7S # Replace with actual GA4 measurement ID from analytics.google.com \ No newline at end of file diff --git a/opr8r/Cargo.lock b/opr8r/Cargo.lock index d7c4adb..db60a6f 100644 --- a/opr8r/Cargo.lock +++ b/opr8r/Cargo.lock @@ -560,7 +560,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "opr8r" -version = "0.1.18" +version = "0.1.19" dependencies = [ "clap", "reqwest", diff --git a/opr8r/Cargo.toml b/opr8r/Cargo.toml index fd131da..965dcad 100644 --- a/opr8r/Cargo.toml +++ b/opr8r/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opr8r" -version = "0.1.18" +version = "0.1.19" edition = "2021" description = "Minimal CLI wrapper for LLM commands in multi-step ticket workflows" license = "MIT" diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index ce1e27e..2e6cd05 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -275,6 +275,7 @@ mod tests { ..Default::default() }], detection_complete: true, + skill_directory_overrides: std::collections::HashMap::new(), }, ..Default::default() } diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 4e2d873..9bcd8ea 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -70,6 +70,7 @@ fn make_test_config(temp_dir: &TempDir) -> Config { ..Default::default() }], detection_complete: true, + skill_directory_overrides: std::collections::HashMap::new(), }, // Disable notifications in tests to avoid DBus requirement on Linux CI notifications: crate::config::NotificationsConfig { diff --git a/src/app.rs b/src/app.rs index 29b6ba9..073dc84 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2389,6 +2389,7 @@ mod tests { ..Default::default() }], detection_complete: true, + skill_directory_overrides: std::collections::HashMap::new(), }, // Disable notifications in tests notifications: crate::config::NotificationsConfig { diff --git a/src/bin/generate_types.rs b/src/bin/generate_types.rs index fa7e8c1..f925970 100644 --- a/src/bin/generate_types.rs +++ b/src/bin/generate_types.rs @@ -29,16 +29,18 @@ use operator::api::providers::kanban::{ JiraProjectStatus, JiraSearchResponse, JiraStatus, JiraStatusRef, JiraUser, }; use operator::config::{ - AgentsConfig, ApiConfig, BackstageConfig, BrandingConfig, CollectionPreset, Config, - DetectedTool, DockerConfig, LaunchConfig, LlmProvider, LlmToolsConfig, LoggingConfig, - NotificationsConfig, PanelNamesConfig, PathsConfig, QueueConfig, RestApiConfig, - TemplatesConfig, ThemeColors, TmuxConfig, ToolCapabilities, UiConfig, YoloConfig, + AgentsConfig, ApiConfig, BackstageConfig, BrandingConfig, CollectionPreset, Config, Delegator, + DelegatorLaunchConfig, DetectedTool, DockerConfig, LaunchConfig, LlmProvider, LlmToolsConfig, + LoggingConfig, NotificationsConfig, PanelNamesConfig, PathsConfig, QueueConfig, RestApiConfig, + SkillDirectoriesOverride, TemplatesConfig, ThemeColors, TmuxConfig, ToolCapabilities, UiConfig, + YoloConfig, }; use operator::queue::LlmTask; use operator::rest::dto::{ - CollectionResponse, CreateFieldRequest, CreateIssueTypeRequest, CreateStepRequest, - FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, StatusResponse, - StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, + CollectionResponse, CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, + CreateStepRequest, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, + FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, SkillEntry, SkillsResponse, + StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, }; use operator::state::{AgentState, CompletedTicket, State}; use operator::types::{ @@ -111,6 +113,9 @@ fn generate_typescript() -> String { DetectedTool::decl(), ToolCapabilities::decl(), LlmProvider::decl(), + SkillDirectoriesOverride::decl(), + Delegator::decl(), + DelegatorLaunchConfig::decl(), CollectionPreset::decl(), TemplatesConfig::decl(), LoggingConfig::decl(), @@ -132,6 +137,14 @@ fn generate_typescript() -> String { CollectionResponse::decl(), HealthResponse::decl(), StatusResponse::decl(), + // Skills DTOs + SkillEntry::decl(), + SkillsResponse::decl(), + // Delegator DTOs + DelegatorResponse::decl(), + DelegatorsResponse::decl(), + CreateDelegatorRequest::decl(), + DelegatorLaunchConfigDto::decl(), // Queue types (src/queue/ticket.rs) LlmTask::decl(), // Jira API types (src/api/providers/kanban/jira.rs) diff --git a/src/config.rs b/src/config.rs index 9d6b30c..3d4868e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,6 +41,9 @@ pub struct Config { /// Version check configuration for automatic update notifications #[serde(default)] pub version_check: VersionCheckConfig, + /// Agent delegator configurations for autonomous ticket launching + #[serde(default)] + pub delegators: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] @@ -678,6 +681,10 @@ pub struct LlmToolsConfig { /// Whether detection has been completed #[serde(default)] pub detection_complete: bool, + + /// Per-tool overrides for skill directories (keyed by tool_name) + #[serde(default)] + pub skill_directory_overrides: std::collections::HashMap, } /// A detected CLI tool (e.g., claude binary) @@ -760,6 +767,57 @@ pub struct LlmProvider { pub sandbox: Option, } +/// Per-tool skill directory overrides +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct SkillDirectoriesOverride { + /// Additional global skill directories + #[serde(default)] + pub global: Vec, + /// Additional project-relative skill directories + #[serde(default)] + pub project: Vec, +} + +/// Agent delegator configuration for autonomous ticket launching +/// +/// A delegator is a named {tool, model} pairing with optional launch configuration +/// that can be used to launch agents for tickets. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct Delegator { + /// Unique name for this delegator (e.g., "claude-opus-auto") + pub name: String, + /// LLM tool name (must match a detected tool, e.g., "claude", "codex") + pub llm_tool: String, + /// Model alias (e.g., "opus", "sonnet", "gpt-4o") + pub model: String, + /// Optional display name for UI + #[serde(default)] + pub display_name: Option, + /// Arbitrary model properties (e.g., reasoning_effort, sandbox) + #[serde(default)] + pub model_properties: std::collections::HashMap, + /// Optional launch configuration + #[serde(default)] + pub launch_config: Option, +} + +/// Launch configuration for a delegator +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct DelegatorLaunchConfig { + /// Run in YOLO (auto-accept) mode + #[serde(default)] + pub yolo: bool, + /// Permission mode override + #[serde(default)] + pub permission_mode: Option, + /// Additional CLI flags + #[serde(default)] + pub flags: Vec, +} + /// Predefined issue type collections #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] @@ -1348,6 +1406,7 @@ impl Default for Config { git: GitConfig::default(), kanban: KanbanConfig::default(), version_check: VersionCheckConfig::default(), + delegators: Vec::new(), } } } @@ -1376,6 +1435,36 @@ mod tests { assert!(types.contains(&"FIX".to_string())); } + #[test] + fn test_delegator_serde_roundtrip() { + let delegator = Delegator { + name: "claude-opus-auto".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: Some("Claude Opus Auto".to_string()), + model_properties: std::collections::HashMap::new(), + launch_config: Some(DelegatorLaunchConfig { + yolo: true, + permission_mode: Some("delegate".to_string()), + flags: vec!["--verbose".to_string()], + }), + }; + + let json = serde_json::to_string(&delegator).unwrap(); + let parsed: Delegator = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.name, "claude-opus-auto"); + assert_eq!(parsed.llm_tool, "claude"); + assert_eq!(parsed.model, "opus"); + assert!(parsed.launch_config.unwrap().yolo); + } + + #[test] + fn test_skill_directories_override_default() { + let override_config = SkillDirectoriesOverride::default(); + assert!(override_config.global.is_empty()); + assert!(override_config.project.is_empty()); + } + #[test] fn test_devops_kanban_has_five_issue_types() { let types = CollectionPreset::DevopsKanban.issue_types(); diff --git a/src/llm/detection.rs b/src/llm/detection.rs index 436f732..4d1c1d3 100644 --- a/src/llm/detection.rs +++ b/src/llm/detection.rs @@ -35,6 +35,7 @@ pub fn detect_all_tools() -> LlmToolsConfig { detected, providers, detection_complete: true, + skill_directory_overrides: std::collections::HashMap::new(), } } diff --git a/src/llm/tool_config.rs b/src/llm/tool_config.rs index b0e23b7..aec1011 100644 --- a/src/llm/tool_config.rs +++ b/src/llm/tool_config.rs @@ -41,6 +41,17 @@ pub struct HookConfig { pub settings_path: String, } +/// Well-known directories where a tool stores skill/command files +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SkillDirectories { + /// Global directories (absolute paths, ~ expanded to home) + #[serde(default)] + pub global: Vec, + /// Project-relative directories + #[serde(default)] + pub project: Vec, +} + /// Configuration for idle state detection #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct IdleDetectionConfig { @@ -83,6 +94,9 @@ pub struct ToolConfig { /// Configuration for idle/awaiting state detection #[serde(default)] pub idle_detection: Option, + /// Well-known directories for skill/command files + #[serde(default)] + pub skill_directories: Option, } impl ToolConfig { @@ -179,4 +193,40 @@ mod tests { let claude = configs.iter().find(|c| c.tool_name == "claude").unwrap(); assert_eq!(claude.display_name(), "Claude Code"); } + + #[test] + fn test_skill_directories_claude() { + let configs = load_all_tool_configs(); + let claude = configs.iter().find(|c| c.tool_name == "claude").unwrap(); + let dirs = claude + .skill_directories + .as_ref() + .expect("claude should have skill_directories"); + assert_eq!(dirs.global, vec!["~/.claude/commands/"]); + assert_eq!(dirs.project, vec![".claude/commands/"]); + } + + #[test] + fn test_skill_directories_codex() { + let configs = load_all_tool_configs(); + let codex = configs.iter().find(|c| c.tool_name == "codex").unwrap(); + let dirs = codex + .skill_directories + .as_ref() + .expect("codex should have skill_directories"); + assert!(dirs.global.is_empty()); + assert_eq!(dirs.project, vec![".codex/", "AGENTS.md"]); + } + + #[test] + fn test_skill_directories_gemini() { + let configs = load_all_tool_configs(); + let gemini = configs.iter().find(|c| c.tool_name == "gemini").unwrap(); + let dirs = gemini + .skill_directories + .as_ref() + .expect("gemini should have skill_directories"); + assert!(dirs.global.is_empty()); + assert_eq!(dirs.project, vec![".gemini/"]); + } } diff --git a/src/llm/tools/claude.json b/src/llm/tools/claude.json index aabca1a..9090ea3 100644 --- a/src/llm/tools/claude.json +++ b/src/llm/tools/claude.json @@ -1,4 +1,5 @@ { + "$schema": "tool_config.schema.json", "tool_name": "claude", "display_name": "Claude Code", "version_command": "claude --version", @@ -21,13 +22,8 @@ "permission_modes": ["default", "plan", "acceptEdits", "delegate"], "command_template": "claude {{config_flags}}{{model_flag}}--session-id {{session_id}} \"$(cat {{prompt_file}})\"", "yolo_flags": ["--dangerously-skip-permissions"], - "idle_detection": { - "idle_patterns": ["^>\\s*$", "^❯\\s*$", "^\\$\\s*$"], - "activity_patterns": ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "Thinking", "Working", "Reading", "Writing", "Searching"], - "hook_config": { - "event_name": "Stop", - "script_path": "~/.claude/hooks/operator-stop.sh", - "settings_path": "~/.claude/settings.json" - } + "skill_directories": { + "global": ["~/.claude/commands/"], + "project": [".claude/commands/"] } } diff --git a/src/llm/tools/codex.json b/src/llm/tools/codex.json index 8240c49..63eeca1 100644 --- a/src/llm/tools/codex.json +++ b/src/llm/tools/codex.json @@ -1,4 +1,5 @@ { + "$schema": "tool_config.schema.json", "tool_name": "codex", "display_name": "OpenAI Codex", "version_command": "codex --version", @@ -18,9 +19,8 @@ }, "command_template": "codex exec {{config_flags}}-m {{model}} --resume {{session_id}} \"$(cat {{prompt_file}})\"", "yolo_flags": ["--full-auto"], - "idle_detection": { - "idle_patterns": ["^>\\s*$", "^\\$\\s*$"], - "activity_patterns": ["Thinking", "Working", "Waiting", "⠋", "⠙", "⠹", "⠸"], - "hook_config": null + "skill_directories": { + "global": [], + "project": [".codex/", "AGENTS.md"] } } diff --git a/src/llm/tools/gemini.json b/src/llm/tools/gemini.json index 3854c85..72b263e 100644 --- a/src/llm/tools/gemini.json +++ b/src/llm/tools/gemini.json @@ -1,4 +1,5 @@ { + "$schema": "tool_config.schema.json", "tool_name": "gemini", "display_name": "Google Gemini", "version_command": "gemini --version", @@ -18,13 +19,8 @@ }, "command_template": "gemini {{config_flags}}--model {{model}} --resume {{session_id}} \"$(cat {{prompt_file}})\"", "yolo_flags": ["--auto-approve", "-y"], - "idle_detection": { - "idle_patterns": ["^>\\s*$", "^gemini>\\s*$", "^\\$\\s*$"], - "activity_patterns": ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "Analyzing", "Processing", "Thinking", "Working"], - "hook_config": { - "event_name": "AfterAgent", - "script_path": "~/.gemini/hooks/operator-stop.sh", - "settings_path": "~/.gemini/settings.json" - } + "skill_directories": { + "global": [], + "project": [".gemini/"] } } diff --git a/src/llm/tools/tool_config.schema.json b/src/llm/tools/tool_config.schema.json index 4973f77..6228258 100644 --- a/src/llm/tools/tool_config.schema.json +++ b/src/llm/tools/tool_config.schema.json @@ -151,6 +151,25 @@ ["--auto-approve", "-y"], ["--full-auto"] ] + }, + "skill_directories": { + "type": "object", + "description": "Well-known directories where this tool stores skill/command files. Used for skill discovery across tools.", + "properties": { + "global": { + "type": "array", + "description": "Global skill directories (absolute paths, ~ expanded to home). Shared across all projects.", + "items": { "type": "string" }, + "default": [] + }, + "project": { + "type": "array", + "description": "Project-relative skill directories. Resolved relative to working directory.", + "items": { "type": "string" }, + "default": [] + } + }, + "additionalProperties": false } }, "examples": [ diff --git a/src/rest/dto.rs b/src/rest/dto.rs index 66190a0..2f73280 100644 --- a/src/rest/dto.rs +++ b/src/rest/dto.rs @@ -881,6 +881,104 @@ pub struct AssessTicketResponse { pub project_name: String, } +// ============================================================================= +// Skills DTOs +// ============================================================================= + +/// A single discovered skill file +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SkillEntry { + /// Tool this skill belongs to (e.g., "claude", "codex") + pub tool_name: String, + /// Filename of the skill (e.g., "commit.md") + pub filename: String, + /// Full path to the skill file + pub file_path: String, + /// Scope: "global" or "project" + pub scope: String, +} + +/// Response for skills listing +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SkillsResponse { + /// List of discovered skills + pub skills: Vec, + /// Total count + pub total: usize, +} + +// ============================================================================= +// Delegator DTOs +// ============================================================================= + +/// Response for a single delegator +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DelegatorResponse { + /// Unique name + pub name: String, + /// LLM tool name (e.g., "claude") + pub llm_tool: String, + /// Model alias (e.g., "opus") + pub model: String, + /// Optional display name + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Arbitrary model properties + pub model_properties: std::collections::HashMap, + /// Optional launch configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub launch_config: Option, +} + +/// Request to create a new delegator +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateDelegatorRequest { + /// Unique name for the delegator + pub name: String, + /// LLM tool name (must match a detected tool) + pub llm_tool: String, + /// Model alias + pub model: String, + /// Optional display name + #[serde(default)] + pub display_name: Option, + /// Arbitrary model properties + #[serde(default)] + pub model_properties: std::collections::HashMap, + /// Optional launch configuration + #[serde(default)] + pub launch_config: Option, +} + +/// Launch configuration DTO for delegators +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DelegatorLaunchConfigDto { + /// Run in YOLO mode + #[serde(default)] + pub yolo: bool, + /// Permission mode override + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, + /// Additional CLI flags + #[serde(default)] + pub flags: Vec, +} + +/// Response listing all delegators +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DelegatorsResponse { + /// List of delegators + pub delegators: Vec, + /// Total count + pub total: usize, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/rest/mod.rs b/src/rest/mod.rs index 7996399..9a95831 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -109,6 +109,16 @@ pub fn build_router(state: ApiState) -> Router { "/api/v1/tickets/:id/steps/:step/complete", post(routes::launch::complete_step), ) + // Skills endpoint + .route("/api/v1/skills", get(routes::skills::list)) + // Delegator endpoints + .route("/api/v1/delegators", get(routes::delegators::list)) + .route("/api/v1/delegators", post(routes::delegators::create)) + .route("/api/v1/delegators/:name", get(routes::delegators::get_one)) + .route( + "/api/v1/delegators/:name", + delete(routes::delegators::delete), + ) .layer( TraceLayer::new_for_http() .on_request(DefaultOnRequest::new().level(Level::INFO)) diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index c927e3e..2f3d971 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -3,9 +3,11 @@ use utoipa::OpenApi; use crate::rest::dto::{ - CollectionResponse, CreateFieldRequest, CreateIssueTypeRequest, CreateStepRequest, + CollectionResponse, CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, + CreateStepRequest, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, LaunchTicketRequest, - LaunchTicketResponse, StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, + LaunchTicketResponse, SkillEntry, SkillsResponse, StatusResponse, StepResponse, + UpdateIssueTypeRequest, UpdateStepRequest, }; use crate::rest::error::ErrorResponse; @@ -43,6 +45,13 @@ use crate::rest::error::ErrorResponse; crate::rest::routes::collections::activate, // Launch endpoints crate::rest::routes::launch::launch_ticket, + // Skills endpoints + crate::rest::routes::skills::list, + // Delegator endpoints + crate::rest::routes::delegators::list, + crate::rest::routes::delegators::get_one, + crate::rest::routes::delegators::create, + crate::rest::routes::delegators::delete, ), components( schemas( @@ -63,6 +72,14 @@ use crate::rest::error::ErrorResponse; CreateStepRequest, UpdateStepRequest, LaunchTicketRequest, + // Skills types + SkillEntry, + SkillsResponse, + // Delegator types + DelegatorResponse, + DelegatorsResponse, + CreateDelegatorRequest, + DelegatorLaunchConfigDto, ) ), tags( @@ -71,6 +88,8 @@ use crate::rest::error::ErrorResponse; (name = "Steps", description = "Step management within issue types"), (name = "Collections", description = "Issue type collection management"), (name = "Launch", description = "Ticket launch operations"), + (name = "Skills", description = "Skill discovery across LLM tools"), + (name = "Delegators", description = "Agent delegator CRUD operations"), ) )] pub struct ApiDoc; diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs new file mode 100644 index 0000000..37da7ed --- /dev/null +++ b/src/rest/routes/delegators.rs @@ -0,0 +1,230 @@ +//! Delegator CRUD endpoints. +//! +//! Manages agent delegator configurations that define named {tool, model} +//! pairings for autonomous ticket launching. + +use axum::{ + extract::{Path, State}, + Json, +}; + +use crate::config::{Config, Delegator, DelegatorLaunchConfig}; +use crate::rest::dto::{ + CreateDelegatorRequest, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, +}; +use crate::rest::error::ApiError; +use crate::rest::state::ApiState; + +/// List all configured delegators +#[utoipa::path( + get, + path = "/api/v1/delegators", + tag = "Delegators", + responses( + (status = 200, description = "List of delegators", body = DelegatorsResponse) + ) +)] +pub async fn list(State(state): State) -> Json { + let delegators: Vec = state + .config + .delegators + .iter() + .map(delegator_to_response) + .collect(); + let total = delegators.len(); + Json(DelegatorsResponse { delegators, total }) +} + +/// Get a single delegator by name +#[utoipa::path( + get, + path = "/api/v1/delegators/{name}", + tag = "Delegators", + params( + ("name" = String, Path, description = "Delegator name") + ), + responses( + (status = 200, description = "Delegator details", body = DelegatorResponse), + (status = 404, description = "Delegator not found") + ) +)] +pub async fn get_one( + State(state): State, + Path(name): Path, +) -> Result, ApiError> { + let delegator = state + .config + .delegators + .iter() + .find(|d| d.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Delegator '{}' not found", name)))?; + + Ok(Json(delegator_to_response(delegator))) +} + +/// Create a new delegator +#[utoipa::path( + post, + path = "/api/v1/delegators", + tag = "Delegators", + request_body = CreateDelegatorRequest, + responses( + (status = 200, description = "Delegator created", body = DelegatorResponse), + (status = 409, description = "Delegator already exists") + ) +)] +pub async fn create( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + // Check for duplicate name + if state.config.delegators.iter().any(|d| d.name == req.name) { + return Err(ApiError::Conflict(format!( + "Delegator '{}' already exists", + req.name + ))); + } + + let delegator = Delegator { + name: req.name, + llm_tool: req.llm_tool, + model: req.model, + display_name: req.display_name, + model_properties: req.model_properties, + launch_config: req.launch_config.map(|lc| DelegatorLaunchConfig { + yolo: lc.yolo, + permission_mode: lc.permission_mode, + flags: lc.flags, + }), + }; + + // Read current config, add delegator, save + let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + config.delegators.push(delegator.clone()); + config + .save() + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {}", e)))?; + + Ok(Json(delegator_to_response(&delegator))) +} + +/// Delete a delegator by name +#[utoipa::path( + delete, + path = "/api/v1/delegators/{name}", + tag = "Delegators", + params( + ("name" = String, Path, description = "Delegator name") + ), + responses( + (status = 200, description = "Delegator deleted", body = DelegatorResponse), + (status = 404, description = "Delegator not found") + ) +)] +pub async fn delete( + State(state): State, + Path(name): Path, +) -> Result, ApiError> { + // Find the delegator first for the response + let delegator = state + .config + .delegators + .iter() + .find(|d| d.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Delegator '{}' not found", name)))?; + let response = delegator_to_response(delegator); + + // Read current config, remove delegator, save + let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + config.delegators.retain(|d| d.name != name); + config + .save() + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {}", e)))?; + + Ok(Json(response)) +} + +/// Convert a Delegator config to a DelegatorResponse DTO +fn delegator_to_response(d: &Delegator) -> DelegatorResponse { + DelegatorResponse { + name: d.name.clone(), + llm_tool: d.llm_tool.clone(), + model: d.model.clone(), + display_name: d.display_name.clone(), + model_properties: d.model_properties.clone(), + launch_config: d.launch_config.as_ref().map(|lc| DelegatorLaunchConfigDto { + yolo: lc.yolo, + permission_mode: lc.permission_mode.clone(), + flags: lc.flags.clone(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + #[tokio::test] + async fn test_list_empty() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let resp = list(State(state)).await; + assert_eq!(resp.total, 0); + assert!(resp.delegators.is_empty()); + } + + #[tokio::test] + async fn test_list_with_delegators() { + let mut config = Config::default(); + config.delegators.push(Delegator { + name: "test-delegator".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: Some("Test".to_string()), + model_properties: std::collections::HashMap::new(), + launch_config: None, + }); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let resp = list(State(state)).await; + assert_eq!(resp.total, 1); + assert_eq!(resp.delegators[0].name, "test-delegator"); + } + + #[tokio::test] + async fn test_get_one_not_found() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = get_one(State(state), Path("nonexistent".to_string())).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_one_found() { + let mut config = Config::default(); + config.delegators.push(Delegator { + name: "my-delegator".to_string(), + llm_tool: "codex".to_string(), + model: "gpt-4o".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: Some(DelegatorLaunchConfig { + yolo: true, + permission_mode: None, + flags: vec![], + }), + }); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = get_one(State(state), Path("my-delegator".to_string())).await; + assert!(result.is_ok()); + let resp = result.unwrap(); + assert_eq!(resp.name, "my-delegator"); + assert_eq!(resp.llm_tool, "codex"); + assert!(resp.launch_config.as_ref().unwrap().yolo); + } +} diff --git a/src/rest/routes/mod.rs b/src/rest/routes/mod.rs index dfb760f..29e54dc 100644 --- a/src/rest/routes/mod.rs +++ b/src/rest/routes/mod.rs @@ -2,9 +2,11 @@ pub mod agents; pub mod collections; +pub mod delegators; pub mod health; pub mod issuetypes; pub mod launch; pub mod projects; pub mod queue; +pub mod skills; pub mod steps; diff --git a/src/rest/routes/skills.rs b/src/rest/routes/skills.rs new file mode 100644 index 0000000..fb6d56d --- /dev/null +++ b/src/rest/routes/skills.rs @@ -0,0 +1,153 @@ +//! Skills discovery endpoint. +//! +//! Scans tool skill directories for .md files and returns them tagged by tool. + +use axum::{extract::State, Json}; + +use crate::llm::tool_config::load_all_tool_configs; +use crate::rest::dto::{SkillEntry, SkillsResponse}; +use crate::rest::state::ApiState; + +/// List all discovered skills across LLM tools +#[utoipa::path( + get, + path = "/api/v1/skills", + tag = "Skills", + responses( + (status = 200, description = "List of discovered skill files", body = SkillsResponse) + ) +)] +pub async fn list(State(state): State) -> Json { + let config = state.config.clone(); + let tool_configs = load_all_tool_configs(); + let mut skills = Vec::new(); + + for tc in &tool_configs { + let skill_dirs = match &tc.skill_directories { + Some(dirs) => dirs.clone(), + None => continue, + }; + + // Check for per-tool overrides in config + let overrides = config + .llm_tools + .skill_directory_overrides + .get(&tc.tool_name); + + // Scan global directories + let mut global_dirs = skill_dirs.global.clone(); + if let Some(ov) = overrides { + global_dirs.extend(ov.global.iter().cloned()); + } + for dir in &global_dirs { + let expanded = expand_tilde(dir); + scan_directory(&expanded, &tc.tool_name, "global", &mut skills); + } + + // Scan project directories (relative to cwd) + let mut project_dirs = skill_dirs.project.clone(); + if let Some(ov) = overrides { + project_dirs.extend(ov.project.iter().cloned()); + } + let cwd = std::env::current_dir().unwrap_or_default(); + for dir in &project_dirs { + let full_path = cwd.join(dir); + // Handle single-file entries (e.g., "AGENTS.md") + if full_path.is_file() { + if let Some(filename) = full_path.file_name() { + skills.push(SkillEntry { + tool_name: tc.tool_name.clone(), + filename: filename.to_string_lossy().to_string(), + file_path: full_path.to_string_lossy().to_string(), + scope: "project".to_string(), + }); + } + } else { + scan_directory( + &full_path.to_string_lossy(), + &tc.tool_name, + "project", + &mut skills, + ); + } + } + } + + let total = skills.len(); + Json(SkillsResponse { skills, total }) +} + +/// Expand ~ to home directory +fn expand_tilde(path: &str) -> String { + if path.starts_with("~/") { + if let Some(home) = dirs::home_dir() { + return format!("{}{}", home.display(), &path[1..]); + } + } + path.to_string() +} + +/// Scan a directory for .md files and add them to the skills list +fn scan_directory(dir: &str, tool_name: &str, scope: &str, skills: &mut Vec) { + let path = std::path::Path::new(dir); + if !path.is_dir() { + return; + } + + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry_path.is_file() { + if let Some(ext) = entry_path.extension() { + if ext == "md" { + if let Some(filename) = entry_path.file_name() { + skills.push(SkillEntry { + tool_name: tool_name.to_string(), + filename: filename.to_string_lossy().to_string(), + file_path: entry_path.to_string_lossy().to_string(), + scope: scope.to_string(), + }); + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expand_tilde() { + let expanded = expand_tilde("~/test"); + assert!(!expanded.starts_with("~/")); + assert!(expanded.ends_with("/test")); + } + + #[test] + fn test_expand_tilde_no_tilde() { + assert_eq!(expand_tilde("/usr/local/bin"), "/usr/local/bin"); + } + + #[test] + fn test_scan_nonexistent_directory() { + let mut skills = Vec::new(); + scan_directory("/nonexistent/path", "test", "global", &mut skills); + assert!(skills.is_empty()); + } + + #[tokio::test] + async fn test_skills_endpoint() { + use crate::config::Config; + use std::path::PathBuf; + + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let resp = list(State(state)).await; + // Should return without error (skills may be empty) + assert!(resp.total == resp.skills.len()); + } +} diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 34b5dca..042556c 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "operator-terminals", "displayName": "Operator! Terminals for vscode", "description": "VS Code terminal integration for Operator! multi-agent orchestration", - "version": "0.1.18", + "version": "0.1.19", "publisher": "untra", "author": { "name": "Samuel Volin", diff --git a/vscode-extension/src/webhook-server.ts b/vscode-extension/src/webhook-server.ts index b3d125d..1c66b9f 100644 --- a/vscode-extension/src/webhook-server.ts +++ b/vscode-extension/src/webhook-server.ts @@ -22,7 +22,7 @@ import { SessionInfo, } from './types'; -const VERSION = '0.1.18'; +const VERSION = '0.1.19'; /** * HTTP server for operator <-> extension communication