diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..d9e9bab --- /dev/null +++ b/agents.md @@ -0,0 +1,772 @@ +# UiPath CLI — Coding Agent Reference + +Complete reference for AI coding agents to use the `uipath` CLI for automating +UiPath workflows: managing solutions, agents, packages, orchestrator resources, +and document understanding. + +## Quick Setup + +```bash +# Install (Linux amd64) +curl -sL "https://github.com/UiPath/uipathcli/releases/latest/download/uipathcli-linux-amd64.tar.gz" | tar -xzv + +# Authenticate (pick one) +uipath config --auth login # OAuth browser login (interactive) +uipath config --auth credentials # Client credentials (automated/CI) +uipath config --auth pat # Personal access token + +# Verify +uipath orchestrator users get +``` + +### Environment Variable Authentication (CI/CD) + +```bash +export UIPATH_ORGANIZATION="my-org" +export UIPATH_TENANT="DefaultTenant" +export UIPATH_PAT="rt_..." # Option A: PAT +# OR +export UIPATH_CLIENT_ID="..." UIPATH_CLIENT_SECRET="..." # Option B: Credentials +``` + +### Config File (`~/.uipath/config`) + +```yaml +profiles: + - name: default + organization: my-org + tenant: DefaultTenant + auth: + clientId: + clientSecret: + - name: alpha + uri: https://alpha.uipath.com + organization: my-org + tenant: DefaultTenant + auth: + pat: rt_... +``` + +Use `--profile alpha` or `UIPATH_PROFILE=alpha` to switch profiles. + +--- + +## Command Structure + +``` +uipath [] [--param value] [global-flags] +``` + +### Global Flags (All Commands) + +| Flag | Env Variable | Default | Description | +|------|-------------|---------|-------------| +| `--debug` | `UIPATH_DEBUG` | false | Show HTTP request/response details | +| `--profile` | `UIPATH_PROFILE` | default | Config profile name | +| `--uri` | `UIPATH_URI` | https://cloud.uipath.com | Server base URL | +| `--organization` | `UIPATH_ORGANIZATION` | | Organization name | +| `--tenant` | `UIPATH_TENANT` | | Tenant name | +| `--output` | `UIPATH_OUTPUT` | json | Output format: `json`, `text` | +| `--query` | | | JMESPath expression for output filtering | +| `--wait` | | | JMESPath condition to poll until true | +| `--wait-timeout` | | 30 | Seconds to wait before timeout | +| `--file` | | | Input file path (use `-` for stdin) | +| `--insecure` | `UIPATH_INSECURE` | false | Skip TLS cert verification | +| `--identity-uri` | `UIPATH_IDENTITY_URI` | | Identity server URL | +| `--call-timeout` | `UIPATH_CALL_TIMEOUT` | 60 | HTTP call timeout (seconds) | +| `--max-attempts` | `UIPATH_MAX_ATTEMPTS` | 3 | Retry count for failed requests | + +--- + +## Complete Command Reference + +### Studio Solution Commands + +Manage UiPath Maestro solutions (.uis files). Solutions are containers holding +agent projects, processes, web apps, and other project types. + +#### `uipath studio solution pack` +Pack a solution directory into a .uis file (ZIP archive). + +```bash +uipath studio solution pack --source ./MySolution --destination ./output.uis +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--source` | string | yes | `.` | Path to solution directory | +| `--destination` | string | no | `.uis` | Output .uis file path | + +**Requires:** `SolutionStorage.json` in source directory. +**Excludes:** `.git/`, `__pycache__/`, `*.pyc` (includes `.agent-builder/`, `.project/`). + +**Output:** +```json +{"status":"Succeeded","package":"/path/to/output.uis","solutionId":"...","name":"","size":12345} +``` + +--- + +#### `uipath studio solution unpack` +Extract a .uis file into a solution directory. + +```bash +uipath studio solution unpack --source ./solution.uis --destination ./MySolution +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--source` | string | yes | | Path to .uis file | +| `--destination` | string | no | `` | Output directory | + +**Output:** +```json +{"status":"Succeeded","directory":"/path/to/MySolution","solutionId":"...","projectCount":1} +``` + +--- + +#### `uipath studio solution push` +Upload a .uis solution file to UiPath Studio Web. + +```bash +uipath studio solution push --source ./solution.uis +uipath studio solution push --source ./solution.uis --solution-id abc-123 # Update existing +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--source` | string | yes | | Path to .uis file | +| `--solution-id` | string | no | | Solution ID to update (omit for new) | + +**Requires:** `--organization` + +--- + +#### `uipath studio solution pull` +Download a solution from Studio Web as a .uis file. + +```bash +uipath studio solution pull --solution-id abc-123 --destination ./solution.uis +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--solution-id` | string | yes | | Solution ID to download | +| `--destination` | string | no | `.uis` | Output file path | + +**Requires:** `--organization` + +--- + +#### `uipath studio solution list` +List all solutions in Studio Web. + +```bash +uipath studio solution list +uipath studio solution list --query "solutions[?status == 'active']" +``` + +**Requires:** `--organization` + +--- + +#### `uipath studio solution publish` +Publish a solution in Studio Web for deployment to Orchestrator. + +```bash +uipath studio solution publish --solution-id abc-123 +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--solution-id` | string | yes | | Solution ID to publish | + +**Requires:** `--organization` + +--- + +### Studio Package Commands + +Build, analyze, test, and publish UiPath Studio automation projects (.nupkg). + +#### `uipath studio package pack` +Package a Studio project into a .nupkg file. + +```bash +uipath studio package pack --source ./MyProject --destination ./output +uipath studio package pack --auto-version true +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--source` | string | yes | `.` | Path to project.json or folder | +| `--destination` | string | yes | `.` | Output folder | +| `--package-version` | string | no | | Specific version string | +| `--auto-version` | boolean | no | false | Auto-generate version | +| `--output-type` | string | no | | Force type: Process, Library, Tests, Objects | +| `--split-output` | boolean | no | false | Split runtime and design libraries | +| `--release-notes` | string | no | | Release notes | + +**Output:** +```json +{"status":"Succeeded","package":"/path/to/Package.1.0.0.nupkg","name":"MyProject","description":"...","projectId":"...","version":"1.0.0"} +``` + +--- + +#### `uipath studio package publish` +Publish a .nupkg package to Orchestrator and create/update a release. + +```bash +uipath studio package publish --source ./MyProject.1.0.0.nupkg --folder Shared +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--source` | string | yes | `.` | Path to .nupkg file or directory | +| `--folder` | string | no | `Shared` | Orchestrator folder name | +| `--folder-id` | integer | no | | Folder ID (alternative to name) | + +**Requires:** `--organization`, `--tenant` + +--- + +#### `uipath studio package analyze` +Run static analysis on a project using governance rules. + +```bash +uipath studio package analyze --source ./MyProject +uipath studio package analyze --query "violations[?severity == 'Error'].[errorCode, description]" --output text +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--source` | string | yes | `.` | Path to project.json or folder | +| `--stop-on-rule-violation` | boolean | no | true | Exit with error on violations | +| `--treat-warnings-as-errors` | boolean | no | false | Treat warnings as errors | +| `--governance-file` | string | no | `uipath.policy.default.json` | Governance policy file | + +**Output:** +```json +{"status":"Succeeded","violations":[{"errorCode":"ST-USG-010","severity":"Warning","description":"...","filePath":"Main.xaml"}]} +``` + +--- + +#### `uipath studio package restore` +Restore project dependencies. + +```bash +uipath studio package restore --source ./MyProject --destination ./packages +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--source` | string | yes | `.` | Path to project.json or folder | +| `--destination` | string | yes | `./packages` | Output folder for dependencies | + +--- + +#### `uipath studio test run` +Run test cases on connected Orchestrator, with multi-project parallel support. + +```bash +uipath studio test run --source ./MyProject +uipath studio test run --source "./project1,./project2" --attach-robot-logs true --results-output junit +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--source` | string[] | yes | `.` | Comma-separated project paths | +| `--timeout` | integer | no | 3600 | Max wait time (seconds) | +| `--results-output` | string | no | `uipath` | Output format: `uipath`, `junit` | +| `--attach-robot-logs` | boolean | no | false | Attach robot logs to results | +| `--folder` | string | no | `Shared` | Orchestrator folder | +| `--folder-id` | integer | no | | Folder ID (hidden) | + +**Requires:** `--organization`, `--tenant` + +--- + +### Orchestrator Commands + +#### `uipath orchestrator buckets download` +Download a file from an Orchestrator storage bucket. + +```bash +uipath orchestrator buckets download --folder-id 2000021 --key 12345 --path "documents/invoice.pdf" +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--folder-id` | integer | yes | | Folder/OrgUnit ID | +| `--key` | integer | yes | | Bucket ID | +| `--path` | string | yes | | File path in bucket | + +**Requires:** `--organization`, `--tenant` + +--- + +#### `uipath orchestrator buckets upload` +Upload a file to an Orchestrator storage bucket. + +```bash +uipath orchestrator buckets upload --folder-id 2000021 --key 12345 --path "docs/report.pdf" --file ./report.pdf +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--folder-id` | integer | yes | | Folder/OrgUnit ID | +| `--key` | integer | yes | | Bucket ID | +| `--path` | string | yes | | Target path in bucket | +| `--file` | binary | yes | | File to upload (use `-` for stdin) | + +**Requires:** `--organization`, `--tenant` + +--- + +#### Auto-Generated Orchestrator Commands + +The CLI auto-generates commands from the Orchestrator OpenAPI specification. +Common operations include: + +```bash +# Folders +uipath orchestrator folders get +uipath orchestrator folders get --query "value[0].Id" + +# Users +uipath orchestrator users get +uipath orchestrator users get --query "value[?Type == 'User']" + +# Jobs +uipath orchestrator jobs start-jobs --folder-id --start-info "ReleaseKey=" +uipath orchestrator jobs get-by-id --folder-id --key +uipath orchestrator jobs stop-jobs --folder-id --strategy SoftStop --job-ids "123,456" + +# Assets +uipath orchestrator assets get --folder-id +uipath orchestrator assets post --folder-id --name "MyAsset" --value-type Text --string-value "value" + +# Releases +uipath orchestrator releases get --folder-id + +# Queues +uipath orchestrator queue-items get --folder-id +uipath orchestrator queue-items post --folder-id --item-data "Name=MyQueue" + +# Processes +uipath orchestrator processes get --folder-id + +# Machines +uipath orchestrator machines get + +# Robots +uipath orchestrator robots get --folder-id + +# Logs +uipath orchestrator robot-logs get --folder-id +``` + +--- + +### Document Understanding Commands + +#### `uipath du digitization digitize` +Digitize a document (synchronous wrapper over async API). + +```bash +uipath du digitization digitize --file invoice.pdf +uipath du digitization digitize --project-id "abc-123" --file invoice.jpg --content-type "image/jpeg" +``` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--project-id` | string | no | `00000000-...` | DU project ID | +| `--file` | binary | yes | | File to digitize | +| `--content-type` | string | no | `application/octet-stream` | MIME type | + +**Requires:** `--organization`, `--tenant` + +#### Auto-Generated DU Commands + +```bash +# Digitization +uipath du digitization start --file invoice.pdf +uipath du digitization get --document-id --wait "status == 'Succeeded'" + +# Classification +uipath du classification classify --file - --query "classificationResults[0].DocumentTypeId" + +# Extraction +uipath du extraction extract --file - --query "extractionResult.ResultsDocument.Fields[?not_null(Values)]" + +# Generative Extraction +uipath du extraction extract --project-id "00000000-0000-0000-0000-000000000001" \ + --extractor-id "generative_extractor" --document-id "$docId" \ + --prompts "id=total; question=The total amount" + +# Projects +uipath du discovery projects +``` + +--- + +## Input & Output Patterns + +### Parameter Types + +| Type | Example | Notes | +|------|---------|-------| +| string | `--name "My Asset"` | | +| integer | `--folder-id 12345` | | +| boolean | `--auto-version true` | | +| binary | `--file invoice.pdf` | File path, or `-` for stdin | +| string[] | `--source "p1,p2"` | Comma-separated or repeated flags | +| object | `--start-info "Key=val;Key2=val2"` | Semicolon-separated, or raw JSON | + +### Nested Objects + +```bash +# Semicolon syntax +uipath orchestrator jobs start-jobs --folder-id 2000021 \ + --start-info "ReleaseKey=abc-123; RunAsMe=false; RuntimeType=Unattended" + +# JSON syntax +uipath orchestrator jobs start-jobs --folder-id 2000021 \ + --start-info '{"releaseKey":"abc-123","runAsMe":false,"runtimeType":"Unattended"}' +``` + +### Piping / stdin + +```bash +# Pipe file to command +cat invoice.pdf | uipath du digitization digitize --file - --content-type "application/pdf" + +# Chain commands +uipath du digitization start --file invoice.jpg | uipath du classification classify --file - + +# Here-doc +uipath orchestrator jobs start-jobs --folder-id 2000021 --file - </ +│ │ └── resource.json # Tool definition +│ └── evals/ # Evaluations (low-code) +│ ├── eval-sets/*.json +│ └── evaluators/*.json +└── resources/solution_folder/ # Deployment resources + ├── package/.json # Package resource + ├── process/agent/.json # Process resource + ├── connection/... # Connection resources + └── index/... # Index resources +``` + +### Agent Types + +**Low-code** (`type: "lowCode"`): Visual builder with system/user prompts, +contentTokens, flow-layout.json, .agent-builder/ directory. + +**Coded** (`type: "coded"`, `targetRuntime: "python"`): Python entry point at +`source_code/main.py` with `@traced` decorators, pydantic models, `uipath` SDK. +Evals go in `coded-evals/` instead of `evals/`. + +### Supported Models + +``` +anthropic.claude-haiku-4-5-20251001-v1:0 +anthropic.claude-sonnet-4-20250514-v1:0 +gpt-4.1-2025-04-14 +gpt-4.1-mini-2025-04-14 +gpt-4o-2024-11-20 +gpt-4o-mini-2024-07-18 +gemini-2.5-flash-preview-04-17 +gemini-2.0-flash-001 +``` + +### Tool/Resource Types + +| Type | $resourceType | Use Case | +|------|--------------|----------| +| Integration | `tool` (external) | Web Search, Web Reader, API calls | +| Agent | `tool` (solution) | Agent-calling-agent | +| Internal | `tool` (built-in) | Analyze Files | +| Context | `context` | RAG/Index semantic search | +| Escalation | `escalation` | HITL via Action Center | + +### Project Types (in .uipx manifest) + +Agent, Process, WebApp, CaseManagement, BusinessRules, Connector, +ProcessOrchestration, Api + +--- + +## Authentication Reference + +### Priority Order +1. **PAT** (`UIPATH_PAT` or `auth.pat`) — highest priority +2. **OAuth Login** (`auth.clientId` + `auth.redirectUri` + `auth.scopes`) +3. **Client Credentials** (`UIPATH_CLIENT_ID`/`UIPATH_CLIENT_SECRET` or config) + +### Scope Requirements + +| Operation | Required Scopes | +|-----------|----------------| +| Orchestrator read | `OR.Users.Read`, `OR.Folders.Read`, etc. | +| Orchestrator write | `OR.Jobs.Write`, `OR.Assets.Write`, etc. | +| Studio operations | Studio-specific scopes | +| Document Understanding | `Du.Digitization`, `Du.Classification`, etc. | + +### Config Commands + +```bash +uipath config # Interactive setup +uipath config --auth credentials # Client credentials flow +uipath config --auth login # OAuth browser flow +uipath config --auth pat # Personal access token +uipath config set --key organization --value x # Set individual values +uipath config set --key uri --value "https://alpha.uipath.com" --profile alpha +uipath config cache clear # Clear token cache +``` + +--- + +## Error Handling + +All commands return JSON with a `status` field (`Succeeded` or `Failed`). +Failed operations also include an `error` field. + +```bash +# Check command exit code +uipath studio package pack --source ./MyProject +if [ $? -ne 0 ]; then + echo "Pack failed" +fi + +# Check status in output +status=$(uipath studio package publish --source ./pkg.nupkg --query "status" --output text) +if [ "$status" = "Failed" ]; then + error=$(uipath studio package publish --source ./pkg.nupkg --query "error" --output text) + echo "Publish failed: $error" +fi +``` + +### Common Errors + +| Error | Cause | Fix | +|-------|-------|-----| +| `Organization is not set` | Missing --organization | Set in config or CLI flag | +| `Tenant is not set` | Missing --tenant | Set in config or CLI flag | +| `Package not found` | Invalid --source path | Check file exists | +| `Service returned status code '503'` | Server error with retry exhaustion | Check service status, increase --max-attempts | +| `Package already exists` | Version conflict on publish | Use --auto-version or bump version | + +--- + +## Tips for Coding Agents + +1. **Always capture IDs**: Use `--query` and `--output text` to capture IDs for + chaining: + ```bash + folderId=$(uipath orchestrator folders get --query "value[0].Id" --output text) + ``` + +2. **Use `--wait` for async ops**: Don't poll manually: + ```bash + uipath du digitization get --document-id $id --wait "status == 'Succeeded'" + ``` + +3. **Debug failures**: Add `--debug` to see full HTTP request/response. + +4. **Solution vs Package**: Solutions (.uis) are Maestro containers with agents. + Packages (.nupkg) are traditional Studio automation projects. + +5. **Studio Web needs only org**: Solution push/pull/list/publish require + `--organization` but NOT `--tenant` (unlike Orchestrator commands). + +6. **Pipe commands**: Chain operations using pipes and `--file -`: + ```bash + uipath du digitization start --file doc.pdf | uipath du extraction extract --file - + ``` + +7. **JMESPath is powerful**: Filter, sort, project, and transform output without + external tools like jq. + +8. **Pack before push**: Always pack a solution directory before pushing: + ```bash + uipath studio solution pack --source ./Sol && uipath studio solution push --source ./Sol.uis + ``` diff --git a/main.go b/main.go index 975d36b..7ba9b7c 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,13 @@ import ( plugin_studio_pack "github.com/UiPath/uipathcli/plugin/studio/pack" plugin_studio_publish "github.com/UiPath/uipathcli/plugin/studio/publish" plugin_studio_restore "github.com/UiPath/uipathcli/plugin/studio/restore" + plugin_solution_create "github.com/UiPath/uipathcli/plugin/studio/solution/create" + plugin_solution_list "github.com/UiPath/uipathcli/plugin/studio/solution/list" + plugin_solution_pack "github.com/UiPath/uipathcli/plugin/studio/solution/pack" + plugin_solution_publish "github.com/UiPath/uipathcli/plugin/studio/solution/publish" + plugin_solution_pull "github.com/UiPath/uipathcli/plugin/studio/solution/pull" + plugin_solution_push "github.com/UiPath/uipathcli/plugin/studio/solution/push" + plugin_solution_unpack "github.com/UiPath/uipathcli/plugin/studio/solution/unpack" plugin_studio_testrun "github.com/UiPath/uipathcli/plugin/studio/testrun" "github.com/UiPath/uipathcli/utils/stream" ) @@ -78,6 +85,13 @@ func main() { plugin_studio_restore.NewPackageRestoreCommand(), plugin_studio_publish.NewPackagePublishCommand(), plugin_studio_testrun.NewTestRunCommand(), + plugin_solution_create.NewSolutionCreateCommand(), + plugin_solution_pack.NewSolutionPackCommand(), + plugin_solution_unpack.NewSolutionUnpackCommand(), + plugin_solution_push.NewSolutionPushCommand(), + plugin_solution_pull.NewSolutionPullCommand(), + plugin_solution_list.NewSolutionListCommand(), + plugin_solution_publish.NewSolutionPublishCommand(), }, ), *configProvider, diff --git a/plugin/studio/solution/create/solution_create_command.go b/plugin/studio/solution/create/solution_create_command.go new file mode 100644 index 0000000..41cdbcc --- /dev/null +++ b/plugin/studio/solution/create/solution_create_command.go @@ -0,0 +1,669 @@ +// Package create implements the command plugin for creating a new UiPath solution +// with an agent project scaffold. +package create + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" +) + +const defaultModel = "anthropic.claude-haiku-4-5-20251001-v1:0" + +// The SolutionCreateCommand creates a new solution with an agent project. +type SolutionCreateCommand struct{} + +func (c SolutionCreateCommand) Command() plugin.Command { + return *plugin.NewCommand("studio"). + WithCategory("solution", "UiPath Solution management", "Pack, unpack, push and pull UiPath Maestro solutions."). + WithOperation("create", "Create Solution", "Creates a new UiPath solution with an agent project"). + WithParameter(plugin.NewParameter("name", plugin.ParameterTypeString, "Solution name"). + WithRequired(true)). + WithParameter(plugin.NewParameter("project-name", plugin.ParameterTypeString, "Agent project name"). + WithDefaultValue("Agent")). + WithParameter(plugin.NewParameter("model", plugin.ParameterTypeString, "LLM model identifier"). + WithDefaultValue(defaultModel)). + WithParameter(plugin.NewParameter("system-prompt", plugin.ParameterTypeString, "System prompt for the agent"). + WithDefaultValue("You are a helpful assistant.")). + WithParameter(plugin.NewParameter("destination", plugin.ParameterTypeString, "Parent directory for the solution"). + WithDefaultValue(".")) +} + +func (c SolutionCreateCommand) Execute(ctx plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { + name := c.getStringParameter("name", "", ctx.Parameters) + if name == "" { + return errors.New("Solution name is required") + } + projectName := c.getStringParameter("project-name", "Agent", ctx.Parameters) + model := c.getStringParameter("model", defaultModel, ctx.Parameters) + systemPrompt := c.getStringParameter("system-prompt", "You are a helpful assistant.", ctx.Parameters) + destination := c.getStringParameter("destination", ".", ctx.Parameters) + destination, _ = filepath.Abs(destination) + + solutionDir := filepath.Join(destination, name) + if _, err := os.Stat(solutionDir); err == nil { + return fmt.Errorf("Directory already exists: %s", solutionDir) + } + + ids := c.generateIds() + err := c.createSolution(solutionDir, name, projectName, model, systemPrompt, ids) + if err != nil { + return err + } + + result := struct { + Status string `json:"status"` + Directory string `json:"directory"` + SolutionId string `json:"solutionId"` + ProjectId string `json:"projectId"` + Name string `json:"name"` + ProjectName string `json:"projectName"` + }{"Succeeded", solutionDir, ids.solutionId, ids.projectId, name, projectName} + + jsonData, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("Create command failed: %w", err) + } + return writer.WriteResponse(*output.NewResponseInfo(http.StatusOK, "200 OK", "HTTP/1.1", map[string][]string{}, bytes.NewReader(jsonData))) +} + +type solutionIds struct { + solutionId string + projectId string + projectKey string + packageKey string + processKey string + entryPointId string + evalSetId string + evaluatorId string + trajectoryId string +} + +func (c SolutionCreateCommand) generateIds() solutionIds { + return solutionIds{ + solutionId: c.generateUUID(), + projectId: c.generateUUID(), + projectKey: c.generateUUID(), + packageKey: c.generateUUID(), + processKey: c.generateUUID(), + entryPointId: c.generateUUID(), + evalSetId: c.generateUUID(), + evaluatorId: c.generateUUID(), + trajectoryId: c.generateUUID(), + } +} + +func (c SolutionCreateCommand) generateUUID() string { + b := make([]byte, 16) + _, _ = io.ReadFull(rand.Reader, b) + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +} + +func (c SolutionCreateCommand) createSolution(solutionDir string, name string, projectName string, model string, systemPrompt string, ids solutionIds) error { + projectDir := filepath.Join(solutionDir, projectName) + dirs := []string{ + projectDir, + filepath.Join(projectDir, ".agent-builder"), + filepath.Join(projectDir, ".project"), + filepath.Join(projectDir, "evals", "eval-sets"), + filepath.Join(projectDir, "evals", "evaluators"), + filepath.Join(solutionDir, "resources", "solution_folder", "package"), + filepath.Join(solutionDir, "resources", "solution_folder", "process", "agent"), + } + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0750); err != nil { + return fmt.Errorf("Cannot create directory '%s': %w", dir, err) + } + } + + writers := []func() error{ + func() error { return c.writeSolutionStorage(solutionDir, ids, projectName) }, + func() error { return c.writeManifest(solutionDir, name, ids, projectName) }, + func() error { return c.writeProjectDescriptor(projectDir, projectName) }, + func() error { return c.writeAgentJson(projectDir, ids, model, systemPrompt) }, + func() error { return c.writeEntryPoints(projectDir, ids) }, + func() error { return c.writeEmptyJson(filepath.Join(projectDir, "flow-layout.json")) }, + func() error { return c.writeEmptyJson(filepath.Join(projectDir, ".project", "JitCustomTypes.json")) }, + func() error { return c.writeAgentBuilderJson(projectDir, projectName, ids, model, systemPrompt) }, + func() error { return c.writeBindings(projectDir) }, + func() error { return c.writeAgentBuilderEntryPoints(projectDir, ids) }, + func() error { return c.writeEvalSet(projectDir, ids) }, + func() error { return c.writeEvaluator(projectDir, ids) }, + func() error { return c.writeTrajectoryEvaluator(projectDir, ids) }, + func() error { return c.writePackageResource(solutionDir, projectName, ids) }, + func() error { return c.writeProcessResource(solutionDir, name, projectName, ids) }, + } + for _, w := range writers { + if err := w(); err != nil { + return err + } + } + return nil +} + +func (c SolutionCreateCommand) writeJSONFile(path string, data interface{}) error { + content, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("Cannot create %s: %w", filepath.Base(path), err) + } + return os.WriteFile(path, append(content, '\n'), 0600) +} + +func (c SolutionCreateCommand) writeEmptyJson(path string) error { + return os.WriteFile(path, []byte("{}\n"), 0600) +} + +// --- Solution-level files --- + +func (c SolutionCreateCommand) writeSolutionStorage(dir string, ids solutionIds, projectName string) error { + data := struct { + SolutionId string `json:"SolutionId"` + Projects []struct { + ProjectId string `json:"ProjectId"` + ProjectRelativePath string `json:"ProjectRelativePath"` + } `json:"Projects"` + }{ + SolutionId: ids.solutionId, + Projects: []struct { + ProjectId string `json:"ProjectId"` + ProjectRelativePath string `json:"ProjectRelativePath"` + }{ + {ids.projectId, projectName + "/project.uiproj"}, + }, + } + return c.writeJSONFile(filepath.Join(dir, "SolutionStorage.json"), data) +} + +func (c SolutionCreateCommand) writeManifest(dir string, name string, ids solutionIds, projectName string) error { + data := struct { + DocVersion string `json:"DocVersion"` + StudioMinVersion string `json:"StudioMinVersion"` + SolutionId string `json:"SolutionId"` + Projects []struct { + Type string `json:"Type"` + ProjectRelativePath string `json:"ProjectRelativePath"` + Id string `json:"Id"` + } `json:"Projects"` + }{ + DocVersion: "1.0.0", + StudioMinVersion: "2025.04.0", + SolutionId: ids.solutionId, + Projects: []struct { + Type string `json:"Type"` + ProjectRelativePath string `json:"ProjectRelativePath"` + Id string `json:"Id"` + }{ + {"Agent", projectName + "/project.uiproj", ids.projectKey}, + }, + } + return c.writeJSONFile(filepath.Join(dir, name+".uipx"), data) +} + +// --- Project files --- + +func (c SolutionCreateCommand) writeProjectDescriptor(dir string, projectName string) error { + data := struct { + ProjectType string `json:"ProjectType"` + Name string `json:"Name"` + Description *string `json:"Description"` + MainFile *string `json:"MainFile"` + }{ + ProjectType: "Agent", + Name: projectName, + } + return c.writeJSONFile(filepath.Join(dir, "project.uiproj"), data) +} + +type agentSettings struct { + Model string `json:"model"` + MaxTokens int `json:"maxTokens"` + Temperature int `json:"temperature"` + Engine string `json:"engine"` + MaxIterations int `json:"maxIterations"` +} + +type agentMessage struct { + Role string `json:"role"` + Content string `json:"content"` + ContentTokens []struct { + Type string `json:"type"` + RawString string `json:"rawString"` + } `json:"contentTokens"` +} + +type jsonSchemaProperty struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` +} + +func (c SolutionCreateCommand) newDefaultSettings(model string) agentSettings { + return agentSettings{ + Model: model, + MaxTokens: 16384, + Temperature: 0, + Engine: "basic-v2", + MaxIterations: 25, + } +} + +func (c SolutionCreateCommand) newMessages(systemPrompt string) []agentMessage { + return []agentMessage{ + { + Role: "system", + Content: systemPrompt, + ContentTokens: []struct { + Type string `json:"type"` + RawString string `json:"rawString"` + }{ + {"simpleText", systemPrompt}, + }, + }, + { + Role: "user", + Content: "query: {{query}}", + ContentTokens: []struct { + Type string `json:"type"` + RawString string `json:"rawString"` + }{ + {"simpleText", "query: "}, + {"variable", "input.query"}, + {"simpleText", ""}, + }, + }, + } +} + +func (c SolutionCreateCommand) newInputSchema() interface{} { + return struct { + Type string `json:"type"` + Properties map[string]jsonSchemaProperty `json:"properties"` + Required []string `json:"required"` + }{ + Type: "object", + Properties: map[string]jsonSchemaProperty{ + "query": {Type: "string"}, + }, + Required: []string{"query"}, + } +} + +func (c SolutionCreateCommand) newOutputSchema() interface{} { + return struct { + Type string `json:"type"` + Properties map[string]jsonSchemaProperty `json:"properties"` + }{ + Type: "object", + Properties: map[string]jsonSchemaProperty{ + "content": {Type: "string", Description: "Output content"}, + }, + } +} + +func (c SolutionCreateCommand) writeAgentJson(dir string, ids solutionIds, model string, systemPrompt string) error { + data := struct { + Version string `json:"version"` + Settings agentSettings `json:"settings"` + InputSchema interface{} `json:"inputSchema"` + OutputSchema interface{} `json:"outputSchema"` + Metadata struct { + StorageVersion string `json:"storageVersion"` + IsConversational bool `json:"isConversational"` + ShowProjectCreationExperience bool `json:"showProjectCreationExperience"` + TargetRuntime string `json:"targetRuntime"` + } `json:"metadata"` + Type string `json:"type"` + ProjectId string `json:"projectId"` + Messages []agentMessage `json:"messages"` + }{ + Version: "1.1.0", + Settings: c.newDefaultSettings(model), + InputSchema: c.newInputSchema(), + OutputSchema: c.newOutputSchema(), + Metadata: struct { + StorageVersion string `json:"storageVersion"` + IsConversational bool `json:"isConversational"` + ShowProjectCreationExperience bool `json:"showProjectCreationExperience"` + TargetRuntime string `json:"targetRuntime"` + }{ + StorageVersion: "44.0.0", + IsConversational: false, + ShowProjectCreationExperience: true, + TargetRuntime: "pythonAgent", + }, + Type: "lowCode", + ProjectId: ids.projectId, + Messages: c.newMessages(systemPrompt), + } + return c.writeJSONFile(filepath.Join(dir, "agent.json"), data) +} + +func (c SolutionCreateCommand) writeEntryPoints(dir string, ids solutionIds) error { + data := struct { + Schema string `json:"$schema"` + Id string `json:"$id"` + EntryPoints []struct { + UniqueId string `json:"uniqueId"` + Type string `json:"type"` + Input interface{} `json:"input"` + Output interface{} `json:"output"` + } `json:"entryPoints"` + }{ + Schema: "https://cloud.uipath.com/draft/2024-12/entry-point", + Id: "entry-points.json", + EntryPoints: []struct { + UniqueId string `json:"uniqueId"` + Type string `json:"type"` + Input interface{} `json:"input"` + Output interface{} `json:"output"` + }{ + { + UniqueId: ids.entryPointId, + Type: "agent", + Input: c.newInputSchema(), + Output: c.newOutputSchema(), + }, + }, + } + return c.writeJSONFile(filepath.Join(dir, "entry-points.json"), data) +} + +// --- Agent builder files --- + +func (c SolutionCreateCommand) writeAgentBuilderJson(dir string, projectName string, ids solutionIds, model string, systemPrompt string) error { + data := struct { + Id string `json:"id"` + Version string `json:"version"` + Name string `json:"name"` + Metadata struct { + StorageVersion string `json:"storageVersion"` + IsConversational bool `json:"isConversational"` + ShowProjectCreationExperience bool `json:"showProjectCreationExperience"` + } `json:"metadata"` + Messages []agentMessage `json:"messages"` + InputSchema interface{} `json:"inputSchema"` + OutputSchema interface{} `json:"outputSchema"` + Settings agentSettings `json:"settings"` + Resources []interface{} `json:"resources"` + Features []interface{} `json:"features"` + }{ + Id: ids.projectId, + Version: "1.1.0", + Name: projectName, + Metadata: struct { + StorageVersion string `json:"storageVersion"` + IsConversational bool `json:"isConversational"` + ShowProjectCreationExperience bool `json:"showProjectCreationExperience"` + }{ + StorageVersion: "44.0.0", + IsConversational: false, + ShowProjectCreationExperience: true, + }, + Messages: c.newMessages(systemPrompt), + InputSchema: c.newInputSchema(), + OutputSchema: c.newOutputSchema(), + Settings: c.newDefaultSettings(model), + Resources: []interface{}{}, + Features: []interface{}{}, + } + return c.writeJSONFile(filepath.Join(dir, ".agent-builder", "agent.json"), data) +} + +func (c SolutionCreateCommand) writeBindings(dir string) error { + data := struct { + Version string `json:"version"` + Resources []interface{} `json:"resources"` + }{"2.0", []interface{}{}} + return c.writeJSONFile(filepath.Join(dir, ".agent-builder", "bindings.json"), data) +} + +func (c SolutionCreateCommand) writeAgentBuilderEntryPoints(dir string, ids solutionIds) error { + return c.writeEntryPoints(filepath.Join(dir, ".agent-builder"), ids) +} + +// --- Evaluation files --- + +func (c SolutionCreateCommand) writeEvalSet(dir string, ids solutionIds) error { + data := struct { + FileName string `json:"fileName"` + Id string `json:"id"` + Name string `json:"name"` + BatchSize int `json:"batchSize"` + EvaluatorRefs []string `json:"evaluatorRefs"` + Evaluations []interface{} `json:"evaluations"` + }{ + FileName: "evaluation-set-default.json", + Id: ids.evalSetId, + Name: "Default Evaluation Set", + BatchSize: 10, + EvaluatorRefs: []string{ids.evaluatorId}, + Evaluations: []interface{}{}, + } + return c.writeJSONFile(filepath.Join(dir, "evals", "eval-sets", "evaluation-set-default.json"), data) +} + +func (c SolutionCreateCommand) writeEvaluator(dir string, ids solutionIds) error { + data := struct { + Version string `json:"version"` + Id string `json:"id"` + Description string `json:"description"` + EvaluatorTypeId string `json:"evaluatorTypeId"` + EvaluatorConfig struct { + Name string `json:"name"` + TargetOutputKey string `json:"targetOutputKey"` + Model string `json:"model"` + Prompt string `json:"prompt"` + Temperature float64 `json:"temperature"` + DefaultEvaluationCriteria interface{} `json:"defaultEvaluationCriteria"` + } `json:"evaluatorConfig"` + }{ + Version: "1.0", + Id: ids.evaluatorId, + Description: "Uses an LLM to judge semantic similarity.", + EvaluatorTypeId: "uipath-llm-judge-output-semantic-similarity", + EvaluatorConfig: struct { + Name string `json:"name"` + TargetOutputKey string `json:"targetOutputKey"` + Model string `json:"model"` + Prompt string `json:"prompt"` + Temperature float64 `json:"temperature"` + DefaultEvaluationCriteria interface{} `json:"defaultEvaluationCriteria"` + }{ + Name: "SemanticSimilarityEvaluator", + TargetOutputKey: "*", + Model: "gpt-4.1-2025-04-14", + Prompt: "Compare the outputs and evaluate semantic similarity.\n\nActual: {{ActualOutput}}\nExpected: {{ExpectedOutput}}\n\nScore 0-100.", + Temperature: 0.0, + DefaultEvaluationCriteria: struct { + ExpectedOutput struct { + Content string `json:"content"` + } `json:"expectedOutput"` + }{}, + }, + } + return c.writeJSONFile(filepath.Join(dir, "evals", "evaluators", "evaluator-default.json"), data) +} + +func (c SolutionCreateCommand) writeTrajectoryEvaluator(dir string, ids solutionIds) error { + data := struct { + Version string `json:"version"` + Id string `json:"id"` + Description string `json:"description"` + EvaluatorTypeId string `json:"evaluatorTypeId"` + EvaluatorConfig struct { + Name string `json:"name"` + Model string `json:"model"` + Prompt string `json:"prompt"` + Temperature float64 `json:"temperature"` + DefaultEvaluationCriteria interface{} `json:"defaultEvaluationCriteria"` + } `json:"evaluatorConfig"` + }{ + Version: "1.0", + Id: ids.trajectoryId, + Description: "Evaluates agent execution trajectory.", + EvaluatorTypeId: "uipath-llm-judge-trajectory-similarity", + EvaluatorConfig: struct { + Name string `json:"name"` + Model string `json:"model"` + Prompt string `json:"prompt"` + Temperature float64 `json:"temperature"` + DefaultEvaluationCriteria interface{} `json:"defaultEvaluationCriteria"` + }{ + Name: "TrajectoryEvaluator", + Model: "gpt-4.1-2025-04-14", + Prompt: "Evaluate trajectory.\n\nExpected: {{ExpectedAgentBehavior}}\nHistory: {{AgentRunHistory}}\n\nScore 0-100.", + Temperature: 0.0, + DefaultEvaluationCriteria: struct { + ExpectedAgentBehavior string `json:"expectedAgentBehavior"` + }{"The agent should correctly perform the task."}, + }, + } + return c.writeJSONFile(filepath.Join(dir, "evals", "evaluators", "evaluator-default-trajectory.json"), data) +} + +// --- Resource files --- + +func (c SolutionCreateCommand) writePackageResource(solutionDir string, projectName string, ids solutionIds) error { + data := struct { + DocVersion string `json:"docVersion"` + Resource struct { + Name string `json:"name"` + Kind string `json:"kind"` + ApiVersion string `json:"apiVersion"` + ProjectKey string `json:"projectKey"` + Dependencies []interface{} `json:"dependencies"` + RuntimeDependencies []interface{} `json:"runtimeDependencies"` + Files []interface{} `json:"files"` + Folders []struct { + FullyQualifiedName string `json:"fullyQualifiedName"` + } `json:"folders"` + Spec struct { + FileName *string `json:"fileName"` + FileReference *string `json:"fileReference"` + Name string `json:"name"` + Description *string `json:"description"` + } `json:"spec"` + Locks []interface{} `json:"locks"` + Key string `json:"key"` + } `json:"resource"` + }{ + DocVersion: "1.0.0", + } + data.Resource.Name = projectName + data.Resource.Kind = "package" + data.Resource.ApiVersion = "orchestrator.uipath.com/v1" + data.Resource.ProjectKey = ids.projectKey + data.Resource.Dependencies = []interface{}{} + data.Resource.RuntimeDependencies = []interface{}{} + data.Resource.Files = []interface{}{} + data.Resource.Folders = []struct { + FullyQualifiedName string `json:"fullyQualifiedName"` + }{{"solution_folder"}} + data.Resource.Spec.Name = projectName + data.Resource.Locks = []interface{}{} + data.Resource.Key = ids.packageKey + return c.writeJSONFile(filepath.Join(solutionDir, "resources", "solution_folder", "package", projectName+".json"), data) +} + +func (c SolutionCreateCommand) writeProcessResource(solutionDir string, solutionName string, projectName string, ids solutionIds) error { + data := struct { + DocVersion string `json:"docVersion"` + Resource struct { + Name string `json:"name"` + Kind string `json:"kind"` + Type string `json:"type"` + ApiVersion string `json:"apiVersion"` + ProjectKey string `json:"projectKey"` + Dependencies []struct { + Name string `json:"name"` + Kind string `json:"kind"` + } `json:"dependencies"` + RuntimeDependencies []interface{} `json:"runtimeDependencies"` + Files []interface{} `json:"files"` + Folders []struct { + FullyQualifiedName string `json:"fullyQualifiedName"` + } `json:"folders"` + Spec struct { + EntryPointUniqueId *string `json:"entryPointUniqueId"` + Type string `json:"type"` + Name string `json:"name"` + Description *string `json:"description"` + Package struct { + Key string `json:"key"` + } `json:"package"` + PackageName string `json:"packageName"` + InputArguments string `json:"inputArguments"` + HiddenForAttendedUser bool `json:"hiddenForAttendedUser"` + AlwaysRunning bool `json:"alwaysRunning"` + AutoStartProcess bool `json:"autoStartProcess"` + TargetFrameworkValue string `json:"targetFrameworkValue"` + AgentMemory bool `json:"agentMemory"` + RetentionAction string `json:"retentionAction"` + RetentionPeriod int `json:"retentionPeriod"` + StaleRetentionAction string `json:"staleRetentionAction"` + StaleRetentionPeriod int `json:"staleRetentionPeriod"` + Tags []interface{} `json:"tags"` + } `json:"spec"` + Locks []interface{} `json:"locks"` + Key string `json:"key"` + } `json:"resource"` + }{ + DocVersion: "1.0.0", + } + data.Resource.Name = projectName + data.Resource.Kind = "process" + data.Resource.Type = "agent" + data.Resource.ApiVersion = "orchestrator.uipath.com/v1" + data.Resource.ProjectKey = ids.projectKey + data.Resource.Dependencies = []struct { + Name string `json:"name"` + Kind string `json:"kind"` + }{{projectName, "package"}} + data.Resource.RuntimeDependencies = []interface{}{} + data.Resource.Files = []interface{}{} + data.Resource.Folders = []struct { + FullyQualifiedName string `json:"fullyQualifiedName"` + }{{"solution_folder"}} + data.Resource.Spec.Type = "Agent" + data.Resource.Spec.Name = projectName + data.Resource.Spec.Package.Key = ids.packageKey + data.Resource.Spec.PackageName = solutionName + ".agent." + projectName + data.Resource.Spec.InputArguments = "{}" + data.Resource.Spec.TargetFrameworkValue = "Portable" + data.Resource.Spec.RetentionAction = "Delete" + data.Resource.Spec.RetentionPeriod = 30 + data.Resource.Spec.StaleRetentionAction = "Delete" + data.Resource.Spec.StaleRetentionPeriod = 180 + data.Resource.Spec.Tags = []interface{}{} + data.Resource.Locks = []interface{}{} + data.Resource.Key = ids.processKey + return c.writeJSONFile(filepath.Join(solutionDir, "resources", "solution_folder", "process", "agent", projectName+".json"), data) +} + +func (c SolutionCreateCommand) getStringParameter(name string, defaultValue string, parameters []plugin.ExecutionParameter) string { + result := defaultValue + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(string); ok { + result = data + break + } + } + } + return result +} + +func NewSolutionCreateCommand() *SolutionCreateCommand { + return &SolutionCreateCommand{} +} diff --git a/plugin/studio/solution/create/solution_create_command_test.go b/plugin/studio/solution/create/solution_create_command_test.go new file mode 100644 index 0000000..aafbc14 --- /dev/null +++ b/plugin/studio/solution/create/solution_create_command_test.go @@ -0,0 +1,385 @@ +package create + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/plugin/studio" + "github.com/UiPath/uipathcli/test" +) + +func TestCreateMissingNameReturnsError(t *testing.T) { + cmd := NewSolutionCreateCommand() + ctx := plugin.ExecutionContext{ + Parameters: []plugin.ExecutionParameter{}, + } + + err := cmd.Execute(ctx, output.NewMemoryOutputWriter(), log.NewDefaultLogger(io.Discard)) + + if err == nil || err.Error() != "Solution name is required" { + t.Errorf("Expected solution name required error, but got: %v", err) + } +} + +func TestCreateNonStringNameReturnsError(t *testing.T) { + cmd := NewSolutionCreateCommand() + ctx := plugin.ExecutionContext{ + Parameters: []plugin.ExecutionParameter{ + {Name: "name", Value: 42}, + }, + } + + err := cmd.Execute(ctx, output.NewMemoryOutputWriter(), log.NewDefaultLogger(io.Discard)) + + if err == nil || err.Error() != "Solution name is required" { + t.Errorf("Expected solution name required error, but got: %v", err) + } +} + +func TestCreateDirectoryAlreadyExistsReturnsError(t *testing.T) { + dir := t.TempDir() + _ = os.MkdirAll(filepath.Join(dir, "MySolution"), 0750) + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "create", "--name", "MySolution", "--destination", dir}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "Directory already exists") { + t.Errorf("Expected directory already exists error, but got: %v", result.Error) + } +} + +func TestCreateSucceeds(t *testing.T) { + dir := t.TempDir() + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "create", "--name", "MyAgent", "--destination", dir}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status Succeeded, but got: %v", result.StdOut) + } + if stdout["name"] != "MyAgent" { + t.Errorf("Expected name MyAgent, but got: %v", stdout["name"]) + } + if stdout["projectName"] != "Agent" { + t.Errorf("Expected projectName Agent, but got: %v", stdout["projectName"]) + } +} + +func TestCreateReturnsSolutionId(t *testing.T) { + dir := t.TempDir() + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "create", "--name", "MyAgent", "--destination", dir}, context) + + stdout := test.ParseOutput(t, result.StdOut) + solutionId, ok := stdout["solutionId"].(string) + if !ok || solutionId == "" { + t.Errorf("Expected non-empty solutionId, but got: %v", stdout["solutionId"]) + } + projectId, ok := stdout["projectId"].(string) + if !ok || projectId == "" { + t.Errorf("Expected non-empty projectId, but got: %v", stdout["projectId"]) + } +} + +func TestCreateGeneratesAllFiles(t *testing.T) { + dir := t.TempDir() + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + test.RunCli([]string{"studio", "solution", "create", "--name", "MyAgent", "--destination", dir}, context) + + expectedFiles := []string{ + "SolutionStorage.json", + "MyAgent.uipx", + "Agent/project.uiproj", + "Agent/agent.json", + "Agent/entry-points.json", + "Agent/flow-layout.json", + "Agent/.agent-builder/agent.json", + "Agent/.agent-builder/bindings.json", + "Agent/.agent-builder/entry-points.json", + "Agent/.project/JitCustomTypes.json", + "Agent/evals/eval-sets/evaluation-set-default.json", + "Agent/evals/evaluators/evaluator-default.json", + "Agent/evals/evaluators/evaluator-default-trajectory.json", + "resources/solution_folder/package/Agent.json", + "resources/solution_folder/process/agent/Agent.json", + } + for _, f := range expectedFiles { + path := filepath.Join(dir, "MyAgent", f) + if _, err := os.Stat(path); err != nil { + t.Errorf("Expected file %s to exist, but got error: %v", f, err) + } + } +} + +func TestCreateSolutionStorageContent(t *testing.T) { + dir := t.TempDir() + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "create", "--name", "MyAgent", "--destination", dir}, context) + + stdout := test.ParseOutput(t, result.StdOut) + solutionId := stdout["solutionId"].(string) + + data, err := os.ReadFile(filepath.Join(dir, "MyAgent", "SolutionStorage.json")) + if err != nil { + t.Fatalf("Cannot read SolutionStorage.json: %v", err) + } + var storage struct { + SolutionId string `json:"SolutionId"` + Projects []struct { + ProjectId string `json:"ProjectId"` + ProjectRelativePath string `json:"ProjectRelativePath"` + } `json:"Projects"` + } + if err := json.Unmarshal(data, &storage); err != nil { + t.Fatalf("Cannot parse SolutionStorage.json: %v", err) + } + if storage.SolutionId != solutionId { + t.Errorf("Expected SolutionId %s, but got: %v", solutionId, storage.SolutionId) + } + if len(storage.Projects) != 1 || storage.Projects[0].ProjectRelativePath != "Agent/project.uiproj" { + t.Errorf("Expected 1 project with Agent/project.uiproj, but got: %v", storage.Projects) + } +} + +func TestCreateAgentJsonContent(t *testing.T) { + dir := t.TempDir() + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + test.RunCli([]string{"studio", "solution", "create", "--name", "MyAgent", "--destination", dir, + "--model", "gpt-4o-2024-11-20", + "--system-prompt", "You are a research assistant."}, context) + + data, err := os.ReadFile(filepath.Join(dir, "MyAgent", "Agent", "agent.json")) + if err != nil { + t.Fatalf("Cannot read agent.json: %v", err) + } + var agent struct { + Version string `json:"version"` + Type string `json:"type"` + Settings struct { + Model string `json:"model"` + } `json:"settings"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(data, &agent); err != nil { + t.Fatalf("Cannot parse agent.json: %v", err) + } + if agent.Version != "1.1.0" { + t.Errorf("Expected version 1.1.0, but got: %v", agent.Version) + } + if agent.Type != "lowCode" { + t.Errorf("Expected type lowCode, but got: %v", agent.Type) + } + if agent.Settings.Model != "gpt-4o-2024-11-20" { + t.Errorf("Expected model gpt-4o-2024-11-20, but got: %v", agent.Settings.Model) + } + if len(agent.Messages) < 2 || agent.Messages[0].Content != "You are a research assistant." { + t.Errorf("Expected system prompt 'You are a research assistant.', but got: %v", agent.Messages) + } +} + +func TestCreateWithCustomProjectName(t *testing.T) { + dir := t.TempDir() + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "create", "--name", "MySolution", "--project-name", "ResearchBot", "--destination", dir}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + if _, err := os.Stat(filepath.Join(dir, "MySolution", "ResearchBot", "agent.json")); err != nil { + t.Errorf("Expected ResearchBot/agent.json to exist: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "MySolution", "resources", "solution_folder", "package", "ResearchBot.json")); err != nil { + t.Errorf("Expected package resource for ResearchBot to exist: %v", err) + } +} + +func TestCreateCanBePackedAndUnpacked(t *testing.T) { + dir := t.TempDir() + createCtx := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "create", "--name", "TestAgent", "--destination", dir}, createCtx) + + if result.Error != nil { + t.Fatalf("Create failed: %v", result.Error) + } + + // Verify SolutionStorage.json exists (required by pack) + solutionDir := filepath.Join(dir, "TestAgent") + if _, err := os.Stat(filepath.Join(solutionDir, "SolutionStorage.json")); err != nil { + t.Fatalf("Expected SolutionStorage.json in created solution: %v", err) + } +} + +func TestCreateManifestContent(t *testing.T) { + dir := t.TempDir() + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + test.RunCli([]string{"studio", "solution", "create", "--name", "MyAgent", "--destination", dir}, context) + + data, err := os.ReadFile(filepath.Join(dir, "MyAgent", "MyAgent.uipx")) + if err != nil { + t.Fatalf("Cannot read .uipx: %v", err) + } + var manifest struct { + DocVersion string `json:"DocVersion"` + SolutionId string `json:"SolutionId"` + Projects []struct { + Type string `json:"Type"` + } `json:"Projects"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("Cannot parse .uipx: %v", err) + } + if manifest.DocVersion != "1.0.0" { + t.Errorf("Expected DocVersion 1.0.0, but got: %v", manifest.DocVersion) + } + if len(manifest.Projects) != 1 || manifest.Projects[0].Type != "Agent" { + t.Errorf("Expected 1 Agent project in manifest, but got: %v", manifest.Projects) + } +} + +func TestCreateEntryPointsContent(t *testing.T) { + dir := t.TempDir() + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + test.RunCli([]string{"studio", "solution", "create", "--name", "MyAgent", "--destination", dir}, context) + + data, err := os.ReadFile(filepath.Join(dir, "MyAgent", "Agent", "entry-points.json")) + if err != nil { + t.Fatalf("Cannot read entry-points.json: %v", err) + } + var ep struct { + EntryPoints []struct { + UniqueId string `json:"uniqueId"` + Type string `json:"type"` + } `json:"entryPoints"` + } + if err := json.Unmarshal(data, &ep); err != nil { + t.Fatalf("Cannot parse entry-points.json: %v", err) + } + if len(ep.EntryPoints) != 1 || ep.EntryPoints[0].Type != "agent" { + t.Errorf("Expected 1 agent entry point, but got: %v", ep.EntryPoints) + } + if ep.EntryPoints[0].UniqueId == "" { + t.Errorf("Expected non-empty uniqueId in entry point") + } +} + +func TestCreateProcessResourceContent(t *testing.T) { + dir := t.TempDir() + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + test.RunCli([]string{"studio", "solution", "create", "--name", "MyAgent", "--destination", dir}, context) + + data, err := os.ReadFile(filepath.Join(dir, "MyAgent", "resources", "solution_folder", "process", "agent", "Agent.json")) + if err != nil { + t.Fatalf("Cannot read process resource: %v", err) + } + var res struct { + Resource struct { + Kind string `json:"kind"` + Type string `json:"type"` + Spec struct { + Type string `json:"type"` + PackageName string `json:"packageName"` + } `json:"spec"` + } `json:"resource"` + } + if err := json.Unmarshal(data, &res); err != nil { + t.Fatalf("Cannot parse process resource: %v", err) + } + if res.Resource.Kind != "process" || res.Resource.Type != "agent" { + t.Errorf("Expected process/agent resource, but got: kind=%v type=%v", res.Resource.Kind, res.Resource.Type) + } + if res.Resource.Spec.PackageName != "MyAgent.agent.Agent" { + t.Errorf("Expected packageName MyAgent.agent.Agent, but got: %v", res.Resource.Spec.PackageName) + } +} + +func TestCreateGeneratesValidUUIDs(t *testing.T) { + cmd := SolutionCreateCommand{} + uuid := cmd.generateUUID() + + parts := strings.Split(uuid, "-") + if len(parts) != 5 { + t.Fatalf("Expected 5 UUID parts, but got %d: %s", len(parts), uuid) + } + if len(parts[0]) != 8 || len(parts[1]) != 4 || len(parts[2]) != 4 || len(parts[3]) != 4 || len(parts[4]) != 12 { + t.Errorf("UUID has wrong part lengths: %s", uuid) + } + // Version 4: third group starts with '4' + if parts[2][0] != '4' { + t.Errorf("Expected UUID version 4 (third group starts with '4'), but got: %s", uuid) + } +} + +func TestCreateDefaultDestination(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionCreateCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "create", "--name", "DefaultDirAgent"}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + if _, err := os.Stat(filepath.Join(tmpDir, "DefaultDirAgent", "SolutionStorage.json")); err != nil { + t.Errorf("Expected solution in current directory: %v", err) + } +} diff --git a/plugin/studio/solution/list/solution_list_command.go b/plugin/studio/solution/list/solution_list_command.go new file mode 100644 index 0000000..aac2ee8 --- /dev/null +++ b/plugin/studio/solution/list/solution_list_command.go @@ -0,0 +1,52 @@ +// Package list implements the command plugin for listing solutions +// from UiPath Studio Web. +package list + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils/api" +) + +// The SolutionListCommand lists solutions from Studio Web. +type SolutionListCommand struct{} + +func (c SolutionListCommand) Command() plugin.Command { + return *plugin.NewCommand("studio"). + WithCategory("solution", "UiPath Solution management", "Pack, unpack, push and pull UiPath Maestro solutions."). + WithOperation("list", "List Solutions", "Lists solutions from UiPath Studio Web") +} + +func (c SolutionListCommand) Execute(ctx plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { + if ctx.Organization == "" { + return errors.New("Organization is not set") + } + + client := api.NewStudioClient(ctx.BaseUri, ctx.Organization, ctx.Auth.Token, ctx.Debug, ctx.Settings, logger) + solutions, err := client.ListSolutions() + if err != nil { + return err + } + + result := solutionListResult{ + Status: "Succeeded", + Solutions: solutions, + } + + jsonData, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("List command failed: %w", err) + } + return writer.WriteResponse(*output.NewResponseInfo(http.StatusOK, "200 OK", "HTTP/1.1", map[string][]string{}, bytes.NewReader(jsonData))) +} + +func NewSolutionListCommand() *SolutionListCommand { + return &SolutionListCommand{} +} diff --git a/plugin/studio/solution/list/solution_list_command_test.go b/plugin/studio/solution/list/solution_list_command_test.go new file mode 100644 index 0000000..fc3bcf5 --- /dev/null +++ b/plugin/studio/solution/list/solution_list_command_test.go @@ -0,0 +1,124 @@ +package list + +import ( + "net/http" + "strings" + "testing" + + "github.com/UiPath/uipathcli/plugin/studio" + "github.com/UiPath/uipathcli/test" +) + +func TestListMissingOrganizationReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionListCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "list"}, context) + + if result.Error == nil || result.Error.Error() != "Organization is not set" { + t.Errorf("Expected organization is not set error, but got: %v", result.Error) + } +} + +func TestListReturnsSolutions(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/List", http.StatusOK, `[{"solutionId":"sol-1","name":"MySolution","status":"active"}]`). + WithCommandPlugin(NewSolutionListCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "list", "--organization", "my-org"}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status Succeeded, but got: %v", result.StdOut) + } +} + +func TestListReturnsSolutionDetails(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/List", http.StatusOK, `[{"solutionId":"sol-1","name":"MySolution","status":"active"},{"solutionId":"sol-2","name":"OtherSolution","status":"draft"}]`). + WithCommandPlugin(NewSolutionListCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "list", "--organization", "my-org"}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + solutions, ok := stdout["solutions"].([]interface{}) + if !ok || len(solutions) != 2 { + t.Errorf("Expected 2 solutions, but got: %v", stdout["solutions"]) + } +} + +func TestListInvalidJsonReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/List", http.StatusOK, `not-valid-json`). + WithCommandPlugin(NewSolutionListCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "list", "--organization", "my-org"}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "invalid response body") { + t.Errorf("Expected invalid response body error, but got: %v", result.Error) + } +} + +func TestListReturnsEmptyList(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/List", http.StatusOK, `[]`). + WithCommandPlugin(NewSolutionListCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "list", "--organization", "my-org"}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status Succeeded, but got: %v", result.StdOut) + } + solutions, ok := stdout["solutions"].([]interface{}) + if !ok || len(solutions) != 0 { + t.Errorf("Expected empty solutions list, but got: %v", stdout["solutions"]) + } +} + +func TestListBadRequestReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/List", http.StatusBadRequest, `{"error":"bad request"}`). + WithCommandPlugin(NewSolutionListCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "list", "--organization", "my-org"}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "400") { + t.Errorf("Expected error with status code 400, but got: %v", result.Error) + } +} + +func TestListServerErrorReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithResponse(http.StatusServiceUnavailable, `{}`). + WithCommandPlugin(NewSolutionListCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "list", "--organization", "my-org"}, context) + + if result.Error == nil { + t.Errorf("Expected error for server failure, but got none") + } +} diff --git a/plugin/studio/solution/list/solution_list_result.go b/plugin/studio/solution/list/solution_list_result.go new file mode 100644 index 0000000..c32b877 --- /dev/null +++ b/plugin/studio/solution/list/solution_list_result.go @@ -0,0 +1,8 @@ +package list + +import "github.com/UiPath/uipathcli/utils/api" + +type solutionListResult struct { + Status string `json:"status"` + Solutions []api.SolutionInfo `json:"solutions"` +} diff --git a/plugin/studio/solution/pack/solution_pack_command.go b/plugin/studio/solution/pack/solution_pack_command.go new file mode 100644 index 0000000..0ef6f77 --- /dev/null +++ b/plugin/studio/solution/pack/solution_pack_command.go @@ -0,0 +1,183 @@ +// Package pack implements the command plugin for packing a UiPath solution +// directory into a .uis file (ZIP archive). +package pack + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" +) + +// The SolutionPackCommand packs a solution directory into a .uis file. +type SolutionPackCommand struct{} + +func (c SolutionPackCommand) Command() plugin.Command { + return *plugin.NewCommand("studio"). + WithCategory("solution", "UiPath Solution management", "Pack, unpack, push and pull UiPath Maestro solutions."). + WithOperation("pack", "Pack Solution", "Packs a solution directory into a .uis file"). + WithParameter(plugin.NewParameter("source", plugin.ParameterTypeString, "Path to solution directory"). + WithRequired(true). + WithDefaultValue(".")). + WithParameter(plugin.NewParameter("destination", plugin.ParameterTypeString, "Output .uis file path"). + WithDefaultValue("")) +} + +func (c SolutionPackCommand) Execute(ctx plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { + source := c.getStringParameter("source", ".", ctx.Parameters) + source, _ = filepath.Abs(source) + destination := c.getStringParameter("destination", "", ctx.Parameters) + + fileInfo, err := os.Stat(source) + if err != nil { + return fmt.Errorf("Solution directory not found: %s", source) + } + if !fileInfo.IsDir() { + return fmt.Errorf("Source is not a directory: %s", source) + } + + solutionStoragePath := filepath.Join(source, "SolutionStorage.json") + if _, err := os.Stat(solutionStoragePath); err != nil { + return fmt.Errorf("SolutionStorage.json not found in %s. This does not appear to be a valid UiPath solution directory", source) + } + + if destination == "" { + destination = filepath.Base(source) + ".uis" + } + destination, _ = filepath.Abs(destination) + + solutionId, solutionName := c.readSolutionInfo(solutionStoragePath) + + params := newSolutionPackParams(source, destination, solutionId, solutionName) + result, err := c.pack(*params) + if err != nil { + return err + } + + jsonData, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("Pack command failed: %w", err) + } + return writer.WriteResponse(*output.NewResponseInfo(http.StatusOK, "200 OK", "HTTP/1.1", map[string][]string{}, bytes.NewReader(jsonData))) +} + +func (c SolutionPackCommand) pack(params solutionPackParams) (*solutionPackResult, error) { + outFile, err := os.Create(params.Destination) + if err != nil { + return nil, fmt.Errorf("Cannot create output file: %w", err) + } + defer func() { _ = outFile.Close() }() + + zipWriter := zip.NewWriter(outFile) + defer func() { _ = zipWriter.Close() }() + + err = filepath.Walk(params.Source, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + relPath, err := filepath.Rel(params.Source, path) + if err != nil { + return err + } + if c.shouldSkip(relPath, info) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + if info.IsDir() { + return nil + } + return c.addFileToZip(zipWriter, path, relPath) + }) + if err != nil { + _ = zipWriter.Close() + _ = outFile.Close() + _ = os.Remove(params.Destination) + return nil, err + } + + // Close zip writer and file before reading size to ensure data is flushed + _ = zipWriter.Close() + _ = outFile.Close() + + fileInfo, err := os.Stat(params.Destination) + size := int64(0) + if err == nil { + size = fileInfo.Size() + } + + return newSucceededSolutionPackResult(params.Destination, params.SolutionId, params.SolutionName, size), nil +} + +func (c SolutionPackCommand) shouldSkip(relPath string, info os.FileInfo) bool { + if relPath == ".git" || strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) { + return true + } + if info.IsDir() && info.Name() == "__pycache__" { + return true + } + if !info.IsDir() && strings.HasSuffix(info.Name(), ".pyc") { + return true + } + return false +} + +func (c SolutionPackCommand) addFileToZip(zipWriter *zip.Writer, path string, relPath string) error { + zipPath := strings.ReplaceAll(relPath, string(filepath.Separator), "/") + w, err := zipWriter.Create(zipPath) + if err != nil { + return fmt.Errorf("Error creating zip entry '%s': %w", zipPath, err) + } + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("Error opening file '%s': %w", path, err) + } + defer func() { _ = f.Close() }() + _, err = io.Copy(w, f) + if err != nil { + return fmt.Errorf("Error writing file '%s': %w", zipPath, err) + } + return nil +} + +func (c SolutionPackCommand) readSolutionInfo(path string) (string, string) { + data, err := os.ReadFile(path) + if err != nil { + return "", "" + } + var storage struct { + SolutionId string `json:"SolutionId"` + } + err = json.Unmarshal(data, &storage) + if err == nil { + return storage.SolutionId, "" + } + return "", "" +} + +func (c SolutionPackCommand) getStringParameter(name string, defaultValue string, parameters []plugin.ExecutionParameter) string { + result := defaultValue + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(string); ok { + result = data + break + } + } + } + return result +} + +func NewSolutionPackCommand() *SolutionPackCommand { + return &SolutionPackCommand{} +} diff --git a/plugin/studio/solution/pack/solution_pack_command_test.go b/plugin/studio/solution/pack/solution_pack_command_test.go new file mode 100644 index 0000000..7d1ff2f --- /dev/null +++ b/plugin/studio/solution/pack/solution_pack_command_test.go @@ -0,0 +1,413 @@ +package pack + +import ( + "archive/zip" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/UiPath/uipathcli/plugin/studio" + "github.com/UiPath/uipathcli/test" +) + +func TestPackMissingSolutionDirectoryReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pack", "--source", "/tmp/not-found-dir"}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "Solution directory not found") { + t.Errorf("Expected solution directory not found error, but got: %v", result.Error) + } +} + +func TestPackNotADirectoryReturnsError(t *testing.T) { + path := test.CreateTempFile(t, "test") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pack", "--source", path}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "Source is not a directory") { + t.Errorf("Expected not a directory error, but got: %v", result.Error) + } +} + +func TestPackMissingSolutionStorageReturnsError(t *testing.T) { + dir := t.TempDir() + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pack", "--source", dir}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "SolutionStorage.json not found") { + t.Errorf("Expected SolutionStorage.json not found error, but got: %v", result.Error) + } +} + +func TestPackCreatesUisFile(t *testing.T) { + dir := createSolutionDirectory(t) + outputPath := filepath.Join(t.TempDir(), "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status Succeeded, but got: %v", result.StdOut) + } + if _, err := os.Stat(outputPath); err != nil { + t.Errorf("Expected .uis file to exist at %s, but got error: %v", outputPath, err) + } +} + +func TestPackContainsAllFiles(t *testing.T) { + dir := createSolutionDirectory(t) + outputPath := filepath.Join(t.TempDir(), "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + reader, err := zip.OpenReader(outputPath) + if err != nil { + t.Fatalf("Cannot open .uis file: %v", err) + } + defer func() { _ = reader.Close() }() + + fileNames := map[string]bool{} + for _, f := range reader.File { + fileNames[f.Name] = true + } + + expectedFiles := []string{ + "SolutionStorage.json", + "Agent/agent.json", + "Agent/project.uiproj", + "Agent/.agent-builder/bindings.json", + } + for _, expected := range expectedFiles { + if !fileNames[expected] { + t.Errorf("Expected .uis to contain %s, but it was not found", expected) + } + } +} + +func TestPackExcludesGitDirectory(t *testing.T) { + dir := createSolutionDirectory(t) + gitDir := filepath.Join(dir, ".git") + _ = os.MkdirAll(gitDir, 0750) + _ = os.WriteFile(filepath.Join(gitDir, "config"), []byte("test"), 0600) + + outputPath := filepath.Join(t.TempDir(), "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + reader, err := zip.OpenReader(outputPath) + if err != nil { + t.Fatalf("Cannot open .uis file: %v", err) + } + defer func() { _ = reader.Close() }() + + for _, f := range reader.File { + if strings.HasPrefix(f.Name, ".git/") || f.Name == ".git" { + t.Errorf("Expected .git to be excluded, but found: %s", f.Name) + } + } +} + +func TestPackExcludesPycache(t *testing.T) { + dir := createSolutionDirectory(t) + cacheDir := filepath.Join(dir, "Agent", "__pycache__") + _ = os.MkdirAll(cacheDir, 0750) + _ = os.WriteFile(filepath.Join(cacheDir, "module.pyc"), []byte("test"), 0600) + + outputPath := filepath.Join(t.TempDir(), "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + reader, err := zip.OpenReader(outputPath) + if err != nil { + t.Fatalf("Cannot open .uis file: %v", err) + } + defer func() { _ = reader.Close() }() + + for _, f := range reader.File { + if strings.Contains(f.Name, "__pycache__") || strings.HasSuffix(f.Name, ".pyc") { + t.Errorf("Expected __pycache__ and .pyc to be excluded, but found: %s", f.Name) + } + } +} + +func TestPackIncludesDotDirectories(t *testing.T) { + dir := createSolutionDirectory(t) + outputPath := filepath.Join(t.TempDir(), "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + reader, err := zip.OpenReader(outputPath) + if err != nil { + t.Fatalf("Cannot open .uis file: %v", err) + } + defer func() { _ = reader.Close() }() + + found := false + for _, f := range reader.File { + if strings.Contains(f.Name, ".agent-builder/") { + found = true + break + } + } + if !found { + t.Errorf("Expected .agent-builder/ to be included in the .uis file") + } +} + +func TestPackDefaultOutputName(t *testing.T) { + dir := createSolutionDirectory(t) + + // Work from a temp directory so default output goes there + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pack", "--source", dir}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + pkg, ok := stdout["package"].(string) + if !ok || !strings.HasSuffix(pkg, ".uis") { + t.Errorf("Expected package path to end with .uis, but got: %v", pkg) + } +} + +func TestPackReadsSolutionId(t *testing.T) { + dir := createSolutionDirectory(t) + outputPath := filepath.Join(t.TempDir(), "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["solutionId"] != "test-solution-id" { + t.Errorf("Expected solutionId test-solution-id, but got: %v", stdout["solutionId"]) + } +} + +func TestPackReportsFileSize(t *testing.T) { + dir := createSolutionDirectory(t) + outputPath := filepath.Join(t.TempDir(), "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + size, ok := stdout["size"].(float64) + if !ok || size <= 0 { + t.Errorf("Expected positive size, but got: %v", stdout["size"]) + } +} + +func TestPackExcludesPycFilesOutsidePycache(t *testing.T) { + dir := createSolutionDirectory(t) + // Create .pyc file directly in the Agent directory (not inside __pycache__) + _ = os.WriteFile(filepath.Join(dir, "Agent", "module.pyc"), []byte("bytecode"), 0600) + + outputPath := filepath.Join(t.TempDir(), "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + reader, err := zip.OpenReader(outputPath) + if err != nil { + t.Fatalf("Cannot open .uis file: %v", err) + } + defer func() { _ = reader.Close() }() + + for _, f := range reader.File { + if strings.HasSuffix(f.Name, ".pyc") { + t.Errorf("Expected .pyc files to be excluded, but found: %s", f.Name) + } + } +} + +func TestPackWithInvalidSolutionStorageJson(t *testing.T) { + dir := t.TempDir() + // Write invalid JSON to SolutionStorage.json + _ = os.WriteFile(filepath.Join(dir, "SolutionStorage.json"), []byte("not valid json"), 0600) + + agentDir := filepath.Join(dir, "Agent") + _ = os.MkdirAll(agentDir, 0750) + _ = os.WriteFile(filepath.Join(agentDir, "agent.json"), []byte(`{"type":"lowCode"}`), 0600) + + outputPath := filepath.Join(t.TempDir(), "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + if result.Error != nil { + t.Errorf("Expected no error even with invalid JSON, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["solutionId"] != "" { + t.Errorf("Expected empty solutionId for invalid JSON, but got: %v", stdout["solutionId"]) + } +} + +func TestPackWithUnreadableFileReturnsError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping permission test on Windows") + } + dir := createSolutionDirectory(t) + // Create a file without read permissions + unreadablePath := filepath.Join(dir, "Agent", "unreadable.txt") + _ = os.WriteFile(unreadablePath, []byte("secret"), 0600) + _ = os.Chmod(unreadablePath, 0000) + + outputPath := filepath.Join(t.TempDir(), "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "Error opening file") { + t.Errorf("Expected error opening unreadable file, but got: %v", result.Error) + } + // Verify cleanup: output file should be removed + if _, err := os.Stat(outputPath); err == nil { + t.Errorf("Expected output file to be cleaned up after error") + } +} + +func TestPackWithUnreadableDirectoryReturnsError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping permission test on Windows") + } + dir := createSolutionDirectory(t) + unreadableDir := filepath.Join(dir, "Agent", "unreadable-dir") + _ = os.MkdirAll(unreadableDir, 0750) + _ = os.WriteFile(filepath.Join(unreadableDir, "secret.txt"), []byte("secret"), 0600) + _ = os.Chmod(unreadableDir, 0000) + defer func() { _ = os.Chmod(unreadableDir, 0750) }() //nolint:gosec // Restore permissions for test cleanup + + outputPath := filepath.Join(t.TempDir(), "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + if result.Error == nil { + t.Errorf("Expected error for unreadable directory, but got none") + } +} + +func TestPackToInvalidDestinationReturnsError(t *testing.T) { + dir := createSolutionDirectory(t) + // Use a path with non-existent parent directory + outputPath := filepath.Join(t.TempDir(), "nonexistent-parent", "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pack", "--source", dir, "--destination", outputPath}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "Cannot create output file") { + t.Errorf("Expected cannot create output file error, but got: %v", result.Error) + } +} + +func TestReadSolutionInfoFileNotFound(t *testing.T) { + cmd := SolutionPackCommand{} + + id, name := cmd.readSolutionInfo(filepath.Join(t.TempDir(), "nonexistent.json")) + + if id != "" || name != "" { + t.Errorf("Expected empty values for missing file, but got id=%v name=%v", id, name) + } +} + +func createSolutionDirectory(t *testing.T) string { + dir := t.TempDir() + + solutionStorage := map[string]interface{}{ + "SolutionId": "test-solution-id", + "Projects": []map[string]interface{}{ + {"ProjectId": "test-project-id", "ProjectRelativePath": "Agent/project.uiproj"}, + }, + } + data, _ := json.Marshal(solutionStorage) + _ = os.WriteFile(filepath.Join(dir, "SolutionStorage.json"), data, 0600) + + agentDir := filepath.Join(dir, "Agent") + _ = os.MkdirAll(agentDir, 0750) + _ = os.WriteFile(filepath.Join(agentDir, "agent.json"), []byte(`{"type":"lowCode"}`), 0600) + _ = os.WriteFile(filepath.Join(agentDir, "project.uiproj"), []byte(`{"ProjectType":"Agent"}`), 0600) + + builderDir := filepath.Join(agentDir, ".agent-builder") + _ = os.MkdirAll(builderDir, 0750) + _ = os.WriteFile(filepath.Join(builderDir, "bindings.json"), []byte(`{"version":"2.0","resources":[]}`), 0600) + + projectDir := filepath.Join(agentDir, ".project") + _ = os.MkdirAll(projectDir, 0750) + _ = os.WriteFile(filepath.Join(projectDir, "JitCustomTypes.json"), []byte(`{}`), 0600) + + return dir +} diff --git a/plugin/studio/solution/pack/solution_pack_params.go b/plugin/studio/solution/pack/solution_pack_params.go new file mode 100644 index 0000000..6fc83c0 --- /dev/null +++ b/plugin/studio/solution/pack/solution_pack_params.go @@ -0,0 +1,12 @@ +package pack + +type solutionPackParams struct { + Source string + Destination string + SolutionId string + SolutionName string +} + +func newSolutionPackParams(source string, destination string, solutionId string, solutionName string) *solutionPackParams { + return &solutionPackParams{source, destination, solutionId, solutionName} +} diff --git a/plugin/studio/solution/pack/solution_pack_result.go b/plugin/studio/solution/pack/solution_pack_result.go new file mode 100644 index 0000000..6608a59 --- /dev/null +++ b/plugin/studio/solution/pack/solution_pack_result.go @@ -0,0 +1,14 @@ +package pack + +type solutionPackResult struct { + Status string `json:"status"` + Package string `json:"package"` + SolutionId string `json:"solutionId"` + Name string `json:"name"` + Size int64 `json:"size"` + Error string `json:"error,omitempty"` +} + +func newSucceededSolutionPackResult(packagePath string, solutionId string, name string, size int64) *solutionPackResult { + return &solutionPackResult{"Succeeded", packagePath, solutionId, name, size, ""} +} diff --git a/plugin/studio/solution/publish/solution_publish_command.go b/plugin/studio/solution/publish/solution_publish_command.go new file mode 100644 index 0000000..996e950 --- /dev/null +++ b/plugin/studio/solution/publish/solution_publish_command.go @@ -0,0 +1,71 @@ +// Package publish implements the command plugin for publishing a solution +// in UiPath Studio Web for deployment. +package publish + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils/api" +) + +// The SolutionPublishCommand publishes a solution in Studio Web. +type SolutionPublishCommand struct{} + +func (c SolutionPublishCommand) Command() plugin.Command { + return *plugin.NewCommand("studio"). + WithCategory("solution", "UiPath Solution management", "Pack, unpack, push and pull UiPath Maestro solutions."). + WithOperation("publish", "Publish Solution", "Publishes a solution in UiPath Studio Web for deployment"). + WithParameter(plugin.NewParameter("solution-id", plugin.ParameterTypeString, "Solution ID to publish"). + WithRequired(true)) +} + +func (c SolutionPublishCommand) Execute(ctx plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { + if ctx.Organization == "" { + return errors.New("Organization is not set") + } + solutionId := c.getStringParameter("solution-id", "", ctx.Parameters) + if solutionId == "" { + return errors.New("Solution ID is required") + } + + client := api.NewStudioClient(ctx.BaseUri, ctx.Organization, ctx.Auth.Token, ctx.Debug, ctx.Settings, logger) + response, err := client.PublishSolution(solutionId) + if err != nil { + return err + } + + result := solutionPublishResult{ + Status: "Succeeded", + RequestId: response.RequestId, + } + + jsonData, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("Publish command failed: %w", err) + } + return writer.WriteResponse(*output.NewResponseInfo(http.StatusOK, "200 OK", "HTTP/1.1", map[string][]string{}, bytes.NewReader(jsonData))) +} + +func (c SolutionPublishCommand) getStringParameter(name string, defaultValue string, parameters []plugin.ExecutionParameter) string { + result := defaultValue + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(string); ok { + result = data + break + } + } + } + return result +} + +func NewSolutionPublishCommand() *SolutionPublishCommand { + return &SolutionPublishCommand{} +} diff --git a/plugin/studio/solution/publish/solution_publish_command_test.go b/plugin/studio/solution/publish/solution_publish_command_test.go new file mode 100644 index 0000000..9e100ea --- /dev/null +++ b/plugin/studio/solution/publish/solution_publish_command_test.go @@ -0,0 +1,173 @@ +package publish + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/plugin/studio" + "github.com/UiPath/uipathcli/test" +) + +func TestPublishSolutionMissingOrganizationReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPublishCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "publish", "--solution-id", "abc-123"}, context) + + if result.Error == nil || result.Error.Error() != "Organization is not set" { + t.Errorf("Expected organization is not set error, but got: %v", result.Error) + } +} + +func TestPublishSolutionMissingSolutionIdReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPublishCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "publish", "--organization", "my-org"}, context) + + if result.Error == nil { + t.Errorf("Expected error for missing solution-id, but got none") + } +} + +func TestPublishSolutionSucceeds(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/Publish-Requests", http.StatusOK, `{"requestId":"req-456","status":"queued"}`). + WithCommandPlugin(NewSolutionPublishCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "publish", "--organization", "my-org", "--solution-id", "abc-123"}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status Succeeded, but got: %v", result.StdOut) + } + if stdout["requestId"] != "req-456" { + t.Errorf("Expected requestId req-456, but got: %v", stdout["requestId"]) + } +} + +func TestPublishSolutionSendsJsonBody(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/Publish-Requests", http.StatusOK, `{"requestId":"req-456"}`). + WithCommandPlugin(NewSolutionPublishCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "publish", "--organization", "my-org", "--solution-id", "abc-123"}, context) + + contentType := result.RequestHeader["content-type"] + if contentType != "application/json" { + t.Errorf("Expected Content-Type application/json, but got: %v", contentType) + } + if !strings.Contains(result.RequestBody, "abc-123") { + t.Errorf("Expected request body to contain solution id, but got: %v", result.RequestBody) + } +} + +func TestPublishSolutionAcceptedStatusSucceeds(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/Publish-Requests", http.StatusAccepted, `{"requestId":"req-789","status":"accepted"}`). + WithCommandPlugin(NewSolutionPublishCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "publish", "--organization", "my-org", "--solution-id", "abc-123"}, context) + + if result.Error != nil { + t.Errorf("Expected no error for 202 Accepted, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["requestId"] != "req-789" { + t.Errorf("Expected requestId req-789, but got: %v", stdout["requestId"]) + } +} + +func TestPublishSolutionCreatedStatusSucceeds(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/Publish-Requests", http.StatusCreated, `{"requestId":"req-created","status":"created"}`). + WithCommandPlugin(NewSolutionPublishCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "publish", "--organization", "my-org", "--solution-id", "abc-123"}, context) + + if result.Error != nil { + t.Errorf("Expected no error for 201 Created, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["requestId"] != "req-created" { + t.Errorf("Expected requestId req-created, but got: %v", stdout["requestId"]) + } +} + +func TestPublishSolutionBadRequestReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/Publish-Requests", http.StatusBadRequest, `{"error":"invalid solution"}`). + WithCommandPlugin(NewSolutionPublishCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "publish", "--organization", "my-org", "--solution-id", "abc-123"}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "400") { + t.Errorf("Expected error with status code 400, but got: %v", result.Error) + } +} + +func TestPublishSolutionWithNonJsonResponseSucceeds(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/Publish-Requests", http.StatusOK, "not-json"). + WithCommandPlugin(NewSolutionPublishCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "publish", "--organization", "my-org", "--solution-id", "abc-123"}, context) + + if result.Error != nil { + t.Errorf("Expected no error for non-JSON response, but got: %v", result.Error) + } +} + +func TestPublishSolutionServerErrorReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithResponse(http.StatusServiceUnavailable, `{}`). + WithCommandPlugin(NewSolutionPublishCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "publish", "--organization", "my-org", "--solution-id", "abc-123"}, context) + + if result.Error == nil { + t.Errorf("Expected error for server failure, but got none") + } +} + +func TestPublishNonStringSolutionIdReturnsError(t *testing.T) { + cmd := NewSolutionPublishCommand() + ctx := plugin.ExecutionContext{ + Organization: "my-org", + Parameters: []plugin.ExecutionParameter{ + {Name: "solution-id", Value: 42}, + }, + } + + err := cmd.Execute(ctx, output.NewMemoryOutputWriter(), log.NewDefaultLogger(io.Discard)) + + if err == nil || err.Error() != "Solution ID is required" { + t.Errorf("Expected solution ID required error, but got: %v", err) + } +} diff --git a/plugin/studio/solution/publish/solution_publish_result.go b/plugin/studio/solution/publish/solution_publish_result.go new file mode 100644 index 0000000..436ccfd --- /dev/null +++ b/plugin/studio/solution/publish/solution_publish_result.go @@ -0,0 +1,7 @@ +package publish + +type solutionPublishResult struct { + Status string `json:"status"` + RequestId string `json:"requestId"` + Error string `json:"error,omitempty"` +} diff --git a/plugin/studio/solution/pull/solution_pull_command.go b/plugin/studio/solution/pull/solution_pull_command.go new file mode 100644 index 0000000..5ecc4f6 --- /dev/null +++ b/plugin/studio/solution/pull/solution_pull_command.go @@ -0,0 +1,99 @@ +// Package pull implements the command plugin for pulling a solution +// from UiPath Studio Web as a .uis file. +package pull + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils/api" +) + +// The SolutionPullCommand pulls a solution from Studio Web. +type SolutionPullCommand struct{} + +func (c SolutionPullCommand) Command() plugin.Command { + return *plugin.NewCommand("studio"). + WithCategory("solution", "UiPath Solution management", "Pack, unpack, push and pull UiPath Maestro solutions."). + WithOperation("pull", "Pull Solution", "Pulls a solution from UiPath Studio Web as a .uis file"). + WithParameter(plugin.NewParameter("solution-id", plugin.ParameterTypeString, "Solution ID to pull"). + WithRequired(true)). + WithParameter(plugin.NewParameter("destination", plugin.ParameterTypeString, "Output .uis file path"). + WithDefaultValue("")) +} + +func (c SolutionPullCommand) Execute(ctx plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { + if ctx.Organization == "" { + return errors.New("Organization is not set") + } + solutionId := c.getStringParameter("solution-id", "", ctx.Parameters) + if solutionId == "" { + return errors.New("Solution ID is required") + } + destination := c.getStringParameter("destination", "", ctx.Parameters) + if destination == "" { + destination = solutionId + ".uis" + } + destination, _ = filepath.Abs(destination) + + params := newSolutionPullParams(solutionId, destination, ctx.BaseUri, ctx.Organization, ctx.Auth, ctx.Debug, ctx.Settings) + result, err := c.pull(*params, logger) + if err != nil { + return err + } + + jsonData, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("Pull command failed: %w", err) + } + return writer.WriteResponse(*output.NewResponseInfo(http.StatusOK, "200 OK", "HTTP/1.1", map[string][]string{}, bytes.NewReader(jsonData))) +} + +func (c SolutionPullCommand) pull(params solutionPullParams, logger log.Logger) (*solutionPullResult, error) { + client := api.NewStudioClient(params.BaseUri, params.Organization, params.Auth.Token, params.Debug, params.Settings, logger) + body, err := client.PullSolution(params.SolutionId) + if err != nil { + return nil, err + } + defer func() { _ = body.Close() }() + + outFile, err := os.Create(params.Destination) + if err != nil { + return nil, fmt.Errorf("Cannot create output file: %w", err) + } + defer func() { _ = outFile.Close() }() + + written, err := io.Copy(outFile, body) + if err != nil { + _ = os.Remove(params.Destination) + return nil, fmt.Errorf("Error writing solution file: %w", err) + } + + return newSucceededSolutionPullResult(params.Destination, params.SolutionId, written), nil +} + +func (c SolutionPullCommand) getStringParameter(name string, defaultValue string, parameters []plugin.ExecutionParameter) string { + result := defaultValue + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(string); ok { + result = data + break + } + } + } + return result +} + +func NewSolutionPullCommand() *SolutionPullCommand { + return &SolutionPullCommand{} +} diff --git a/plugin/studio/solution/pull/solution_pull_command_test.go b/plugin/studio/solution/pull/solution_pull_command_test.go new file mode 100644 index 0000000..b34309c --- /dev/null +++ b/plugin/studio/solution/pull/solution_pull_command_test.go @@ -0,0 +1,170 @@ +package pull + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/plugin/studio" + "github.com/UiPath/uipathcli/test" +) + +func TestPullMissingOrganizationReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPullCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pull", "--solution-id", "abc-123"}, context) + + if result.Error == nil || result.Error.Error() != "Organization is not set" { + t.Errorf("Expected organization is not set error, but got: %v", result.Error) + } +} + +func TestPullMissingSolutionIdReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPullCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pull", "--organization", "my-org"}, context) + + if result.Error == nil { + t.Errorf("Expected error for missing solution-id, but got none") + } +} + +func TestPullDownloadsSolution(t *testing.T) { + destPath := filepath.Join(t.TempDir(), "downloaded.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/Pull?solutionId=abc-123", http.StatusOK, "fake-uis-content"). + WithCommandPlugin(NewSolutionPullCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pull", "--organization", "my-org", "--solution-id", "abc-123", "--destination", destPath}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status Succeeded, but got: %v", result.StdOut) + } + if stdout["solutionId"] != "abc-123" { + t.Errorf("Expected solutionId abc-123, but got: %v", stdout["solutionId"]) + } + + data, err := os.ReadFile(destPath) + if err != nil { + t.Fatalf("Expected file to be created at %s, but got error: %v", destPath, err) + } + if string(data) != "fake-uis-content" { + t.Errorf("Expected file content 'fake-uis-content', but got: %v", string(data)) + } +} + +func TestPullDefaultDestination(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/Pull?solutionId=abc-123", http.StatusOK, "content"). + WithCommandPlugin(NewSolutionPullCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pull", "--organization", "my-org", "--solution-id", "abc-123"}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + filePath, ok := stdout["file"].(string) + if !ok || !strings.HasSuffix(filePath, "abc-123.uis") { + t.Errorf("Expected file to end with abc-123.uis, but got: %v", filePath) + } +} + +func TestPullReportsFileSize(t *testing.T) { + destPath := filepath.Join(t.TempDir(), "downloaded.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/Pull?solutionId=abc-123", http.StatusOK, "fake-uis-content"). + WithCommandPlugin(NewSolutionPullCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pull", "--organization", "my-org", "--solution-id", "abc-123", "--destination", destPath}, context) + + stdout := test.ParseOutput(t, result.StdOut) + size, ok := stdout["size"].(float64) + if !ok || size <= 0 { + t.Errorf("Expected positive size, but got: %v", stdout["size"]) + } +} + +func TestPullToInvalidDestinationReturnsError(t *testing.T) { + destPath := filepath.Join(t.TempDir(), "nonexistent-parent", "test.uis") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/Pull?solutionId=abc-123", http.StatusOK, "content"). + WithCommandPlugin(NewSolutionPullCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pull", "--organization", "my-org", "--solution-id", "abc-123", "--destination", destPath}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "Cannot create output file") { + t.Errorf("Expected cannot create output file error, but got: %v", result.Error) + } +} + +func TestPullNotFoundReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/Pull?solutionId=not-found", http.StatusNotFound, `{"error":"Solution not found"}`). + WithCommandPlugin(NewSolutionPullCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pull", "--organization", "my-org", "--solution-id", "not-found"}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "404") { + t.Errorf("Expected error with status code 404, but got: %v", result.Error) + } +} + +func TestPullServerErrorReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithResponse(http.StatusServiceUnavailable, `{}`). + WithCommandPlugin(NewSolutionPullCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "pull", "--organization", "my-org", "--solution-id", "abc-123"}, context) + + if result.Error == nil { + t.Errorf("Expected error for server failure, but got none") + } +} + +func TestPullNonStringSolutionIdReturnsError(t *testing.T) { + cmd := NewSolutionPullCommand() + ctx := plugin.ExecutionContext{ + Organization: "my-org", + Parameters: []plugin.ExecutionParameter{ + {Name: "solution-id", Value: 42}, + }, + } + + err := cmd.Execute(ctx, output.NewMemoryOutputWriter(), log.NewDefaultLogger(io.Discard)) + + if err == nil || err.Error() != "Solution ID is required" { + t.Errorf("Expected solution ID required error, but got: %v", err) + } +} diff --git a/plugin/studio/solution/pull/solution_pull_params.go b/plugin/studio/solution/pull/solution_pull_params.go new file mode 100644 index 0000000..c1ae2bc --- /dev/null +++ b/plugin/studio/solution/pull/solution_pull_params.go @@ -0,0 +1,28 @@ +package pull + +import ( + "net/url" + + "github.com/UiPath/uipathcli/plugin" +) + +type solutionPullParams struct { + SolutionId string + Destination string + BaseUri url.URL + Organization string + Auth plugin.AuthResult + Debug bool + Settings plugin.ExecutionSettings +} + +func newSolutionPullParams( + solutionId string, + destination string, + baseUri url.URL, + organization string, + auth plugin.AuthResult, + debug bool, + settings plugin.ExecutionSettings) *solutionPullParams { + return &solutionPullParams{solutionId, destination, baseUri, organization, auth, debug, settings} +} diff --git a/plugin/studio/solution/pull/solution_pull_result.go b/plugin/studio/solution/pull/solution_pull_result.go new file mode 100644 index 0000000..7205f7d --- /dev/null +++ b/plugin/studio/solution/pull/solution_pull_result.go @@ -0,0 +1,13 @@ +package pull + +type solutionPullResult struct { + Status string `json:"status"` + File string `json:"file"` + SolutionId string `json:"solutionId"` + Size int64 `json:"size"` + Error string `json:"error,omitempty"` +} + +func newSucceededSolutionPullResult(filePath string, solutionId string, size int64) *solutionPullResult { + return &solutionPullResult{"Succeeded", filePath, solutionId, size, ""} +} diff --git a/plugin/studio/solution/push/solution_push_command.go b/plugin/studio/solution/push/solution_push_command.go new file mode 100644 index 0000000..66d3372 --- /dev/null +++ b/plugin/studio/solution/push/solution_push_command.go @@ -0,0 +1,92 @@ +// Package push implements the command plugin for pushing a .uis solution +// file to UiPath Studio Web. +package push + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils/api" + "github.com/UiPath/uipathcli/utils/stream" + "github.com/UiPath/uipathcli/utils/visualization" +) + +// The SolutionPushCommand pushes a .uis file to Studio Web. +type SolutionPushCommand struct{} + +func (c SolutionPushCommand) Command() plugin.Command { + return *plugin.NewCommand("studio"). + WithCategory("solution", "UiPath Solution management", "Pack, unpack, push and pull UiPath Maestro solutions."). + WithOperation("push", "Push Solution", "Pushes a .uis solution file to UiPath Studio Web"). + WithParameter(plugin.NewParameter("source", plugin.ParameterTypeString, "Path to .uis file"). + WithRequired(true)). + WithParameter(plugin.NewParameter("solution-id", plugin.ParameterTypeString, "Solution ID to update (optional, for updating existing solutions)"). + WithDefaultValue("")) +} + +func (c SolutionPushCommand) Execute(ctx plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { + if ctx.Organization == "" { + return errors.New("Organization is not set") + } + source := c.getStringParameter("source", "", ctx.Parameters) + if source == "" { + return errors.New("Source .uis file is required") + } + source, _ = filepath.Abs(source) + solutionId := c.getStringParameter("solution-id", "", ctx.Parameters) + + if _, err := os.Stat(source); err != nil { + return fmt.Errorf("File not found: %s", source) + } + + params := newSolutionPushParams(source, solutionId, ctx.BaseUri, ctx.Organization, ctx.Auth, ctx.Debug, ctx.Settings) + result, err := c.push(*params, logger) + if err != nil { + return err + } + + jsonData, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("Push command failed: %w", err) + } + return writer.WriteResponse(*output.NewResponseInfo(http.StatusOK, "200 OK", "HTTP/1.1", map[string][]string{}, bytes.NewReader(jsonData))) +} + +func (c SolutionPushCommand) push(params solutionPushParams, logger log.Logger) (*solutionPushResult, error) { + file := stream.NewFileStream(params.Source) + uploadBar := visualization.NewProgressBar(logger) + defer uploadBar.Remove() + + client := api.NewStudioClient(params.BaseUri, params.Organization, params.Auth.Token, params.Debug, params.Settings, logger) + response, err := client.PushSolution(file, params.SolutionId, uploadBar) + if err != nil { + return nil, err + } + + return newSucceededSolutionPushResult(params.Source, response.SolutionId), nil +} + +func (c SolutionPushCommand) getStringParameter(name string, defaultValue string, parameters []plugin.ExecutionParameter) string { + result := defaultValue + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(string); ok { + result = data + break + } + } + } + return result +} + +func NewSolutionPushCommand() *SolutionPushCommand { + return &SolutionPushCommand{} +} diff --git a/plugin/studio/solution/push/solution_push_command_test.go b/plugin/studio/solution/push/solution_push_command_test.go new file mode 100644 index 0000000..2ae6209 --- /dev/null +++ b/plugin/studio/solution/push/solution_push_command_test.go @@ -0,0 +1,186 @@ +package push + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/plugin/studio" + "github.com/UiPath/uipathcli/test" +) + +func TestPushMissingOrganizationReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPushCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "push", "--source", "test.uis"}, context) + + if result.Error == nil || result.Error.Error() != "Organization is not set" { + t.Errorf("Expected organization is not set error, but got: %v", result.Error) + } +} + +func TestPushMissingSourceReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPushCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "push", "--organization", "my-org"}, context) + + if result.Error == nil { + t.Errorf("Expected error for missing source, but got none") + } +} + +func TestPushFileNotFoundReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionPushCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "push", "--organization", "my-org", "--source", "/tmp/not-found.uis"}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "File not found") { + t.Errorf("Expected file not found error, but got: %v", result.Error) + } +} + +func TestPushUploadsToStudioWeb(t *testing.T) { + path := test.CreateTempFile(t, "test-solution-content") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/Push", http.StatusOK, `{"solutionId":"abc-123","status":"ok"}`). + WithCommandPlugin(NewSolutionPushCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "push", "--organization", "my-org", "--source", path}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status Succeeded, but got: %v", result.StdOut) + } + if stdout["solutionId"] != "abc-123" { + t.Errorf("Expected solutionId abc-123, but got: %v", stdout["solutionId"]) + } +} + +func TestPushSendsMultipartRequest(t *testing.T) { + path := test.CreateTempFile(t, "test-content") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/Push", http.StatusOK, `{"solutionId":"abc-123"}`). + WithCommandPlugin(NewSolutionPushCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "push", "--organization", "my-org", "--source", path}, context) + + contentType := result.RequestHeader["content-type"] + if !strings.HasPrefix(contentType, "multipart/form-data; boundary=") { + t.Errorf("Expected Content-Type to be multipart/form-data, but got: %v", contentType) + } +} + +func TestPushWithSolutionIdIncludesQueryParam(t *testing.T) { + path := test.CreateTempFile(t, "test-content") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/Push?solutionId=existing-id", http.StatusOK, `{"solutionId":"existing-id"}`). + WithCommandPlugin(NewSolutionPushCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "push", "--organization", "my-org", "--source", path, "--solution-id", "existing-id"}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } +} + +func TestPushCreatedStatusSucceeds(t *testing.T) { + path := test.CreateTempFile(t, "test-content") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/Push", http.StatusCreated, `{"solutionId":"new-id"}`). + WithCommandPlugin(NewSolutionPushCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "push", "--organization", "my-org", "--source", path}, context) + + if result.Error != nil { + t.Errorf("Expected no error for 201 Created, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["solutionId"] != "new-id" { + t.Errorf("Expected solutionId new-id, but got: %v", stdout["solutionId"]) + } +} + +func TestPushBadRequestReturnsError(t *testing.T) { + path := test.CreateTempFile(t, "test-content") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/Push", http.StatusBadRequest, `{"error":"invalid file"}`). + WithCommandPlugin(NewSolutionPushCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "push", "--organization", "my-org", "--source", path}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "400") { + t.Errorf("Expected error with status code 400, but got: %v", result.Error) + } +} + +func TestPushWithNonJsonResponseSucceeds(t *testing.T) { + path := test.CreateTempFile(t, "test-content") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithUrlResponse("/my-org/studio_/backend/api/v1/ExternalSolution/Push", http.StatusOK, "not-json"). + WithCommandPlugin(NewSolutionPushCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "push", "--organization", "my-org", "--source", path}, context) + + if result.Error != nil { + t.Errorf("Expected no error for non-JSON response, but got: %v", result.Error) + } +} + +func TestPushServerErrorReturnsError(t *testing.T) { + path := test.CreateTempFile(t, "test-content") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithResponse(http.StatusServiceUnavailable, `{}`). + WithCommandPlugin(NewSolutionPushCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "push", "--organization", "my-org", "--source", path}, context) + + if result.Error == nil { + t.Errorf("Expected error for server failure, but got none") + } +} + +func TestPushNonStringSourceReturnsError(t *testing.T) { + cmd := NewSolutionPushCommand() + ctx := plugin.ExecutionContext{ + Organization: "my-org", + Parameters: []plugin.ExecutionParameter{ + {Name: "source", Value: 42}, + }, + } + + err := cmd.Execute(ctx, output.NewMemoryOutputWriter(), log.NewDefaultLogger(io.Discard)) + + if err == nil || err.Error() != "Source .uis file is required" { + t.Errorf("Expected source required error, but got: %v", err) + } +} diff --git a/plugin/studio/solution/push/solution_push_params.go b/plugin/studio/solution/push/solution_push_params.go new file mode 100644 index 0000000..f50506b --- /dev/null +++ b/plugin/studio/solution/push/solution_push_params.go @@ -0,0 +1,28 @@ +package push + +import ( + "net/url" + + "github.com/UiPath/uipathcli/plugin" +) + +type solutionPushParams struct { + Source string + SolutionId string + BaseUri url.URL + Organization string + Auth plugin.AuthResult + Debug bool + Settings plugin.ExecutionSettings +} + +func newSolutionPushParams( + source string, + solutionId string, + baseUri url.URL, + organization string, + auth plugin.AuthResult, + debug bool, + settings plugin.ExecutionSettings) *solutionPushParams { + return &solutionPushParams{source, solutionId, baseUri, organization, auth, debug, settings} +} diff --git a/plugin/studio/solution/push/solution_push_result.go b/plugin/studio/solution/push/solution_push_result.go new file mode 100644 index 0000000..9079662 --- /dev/null +++ b/plugin/studio/solution/push/solution_push_result.go @@ -0,0 +1,12 @@ +package push + +type solutionPushResult struct { + Status string `json:"status"` + Package string `json:"package"` + SolutionId string `json:"solutionId"` + Error string `json:"error,omitempty"` +} + +func newSucceededSolutionPushResult(packagePath string, solutionId string) *solutionPushResult { + return &solutionPushResult{"Succeeded", packagePath, solutionId, ""} +} diff --git a/plugin/studio/solution/unpack/solution_unpack_command.go b/plugin/studio/solution/unpack/solution_unpack_command.go new file mode 100644 index 0000000..f759c3c --- /dev/null +++ b/plugin/studio/solution/unpack/solution_unpack_command.go @@ -0,0 +1,162 @@ +// Package unpack implements the command plugin for extracting a .uis file +// (ZIP archive) into a solution directory. +package unpack + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" +) + +// The SolutionUnpackCommand extracts a .uis file into a directory. +type SolutionUnpackCommand struct{} + +func (c SolutionUnpackCommand) Command() plugin.Command { + return *plugin.NewCommand("studio"). + WithCategory("solution", "UiPath Solution management", "Pack, unpack, push and pull UiPath Maestro solutions."). + WithOperation("unpack", "Unpack Solution", "Extracts a .uis file into a solution directory"). + WithParameter(plugin.NewParameter("source", plugin.ParameterTypeString, "Path to .uis file"). + WithRequired(true)). + WithParameter(plugin.NewParameter("destination", plugin.ParameterTypeString, "Output directory path"). + WithDefaultValue("")) +} + +func (c SolutionUnpackCommand) Execute(ctx plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { + source := c.getStringParameter("source", "", ctx.Parameters) + if source == "" { + return errors.New("Source .uis file is required") + } + source, _ = filepath.Abs(source) + destination := c.getStringParameter("destination", "", ctx.Parameters) + + if _, err := os.Stat(source); err != nil { + return fmt.Errorf("File not found: %s", source) + } + + if destination == "" { + basename := filepath.Base(source) + destination = strings.TrimSuffix(basename, filepath.Ext(basename)) + } + destination, _ = filepath.Abs(destination) + + params := newSolutionUnpackParams(source, destination) + result, err := c.unpack(*params) + if err != nil { + return err + } + + jsonData, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("Unpack command failed: %w", err) + } + return writer.WriteResponse(*output.NewResponseInfo(http.StatusOK, "200 OK", "HTTP/1.1", map[string][]string{}, bytes.NewReader(jsonData))) +} + +func (c SolutionUnpackCommand) unpack(params solutionUnpackParams) (*solutionUnpackResult, error) { + reader, err := zip.OpenReader(params.Source) + if err != nil { + return nil, fmt.Errorf("Cannot open .uis file: %w", err) + } + defer func() { _ = reader.Close() }() + + for _, file := range reader.File { + err := c.extractFile(file, params.Destination) + if err != nil { + return nil, err + } + } + + solutionId, projectCount := c.readSolutionInfo(filepath.Join(params.Destination, "SolutionStorage.json")) + + return newSucceededSolutionUnpackResult(params.Destination, solutionId, projectCount), nil +} + +const maxArchiveFileSize = 1 * 1024 * 1024 * 1024 + +func (c SolutionUnpackCommand) extractFile(file *zip.File, destination string) error { + destPath, err := c.sanitizeArchivePath(destination, file.Name) + if err != nil { + return err + } + + if file.FileInfo().IsDir() { + return os.MkdirAll(destPath, 0750) + } + + err = os.MkdirAll(filepath.Dir(destPath), 0750) + if err != nil { + return fmt.Errorf("Cannot create directory for '%s': %w", destPath, err) + } + + rc, err := file.Open() + if err != nil { + return fmt.Errorf("Cannot read archive entry '%s': %w", file.Name, err) + } + defer func() { _ = rc.Close() }() + + outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + return fmt.Errorf("Cannot create file '%s': %w", destPath, err) + } + defer func() { _ = outFile.Close() }() + + _, err = io.CopyN(outFile, rc, maxArchiveFileSize) + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("Error extracting '%s': %w", file.Name, err) + } + return nil +} + +func (c SolutionUnpackCommand) sanitizeArchivePath(directory string, name string) (string, error) { + result := filepath.Join(directory, name) + if strings.HasPrefix(result, filepath.Clean(directory)) { + return result, nil + } + return "", fmt.Errorf("File path '%s' is not allowed", name) +} + +func (c SolutionUnpackCommand) readSolutionInfo(path string) (string, int) { + data, err := os.ReadFile(path) + if err != nil { + return "", 0 + } + var storage struct { + SolutionId string `json:"SolutionId"` + Projects []struct { + ProjectId string `json:"ProjectId"` + } `json:"Projects"` + } + err = json.Unmarshal(data, &storage) + if err != nil { + return "", 0 + } + return storage.SolutionId, len(storage.Projects) +} + +func (c SolutionUnpackCommand) getStringParameter(name string, defaultValue string, parameters []plugin.ExecutionParameter) string { + result := defaultValue + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(string); ok { + result = data + break + } + } + } + return result +} + +func NewSolutionUnpackCommand() *SolutionUnpackCommand { + return &SolutionUnpackCommand{} +} diff --git a/plugin/studio/solution/unpack/solution_unpack_command_test.go b/plugin/studio/solution/unpack/solution_unpack_command_test.go new file mode 100644 index 0000000..d90c8e0 --- /dev/null +++ b/plugin/studio/solution/unpack/solution_unpack_command_test.go @@ -0,0 +1,367 @@ +package unpack + +import ( + "archive/zip" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/plugin/studio" + "github.com/UiPath/uipathcli/test" +) + +func TestUnpackMissingSourceReturnsError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", "/tmp/not-found.uis"}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "File not found") { + t.Errorf("Expected file not found error, but got: %v", result.Error) + } +} + +func TestUnpackInvalidZipReturnsError(t *testing.T) { + path := test.CreateTempFile(t, "not-a-zip") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", path}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "Cannot open .uis file") { + t.Errorf("Expected invalid zip error, but got: %v", result.Error) + } +} + +func TestUnpackExtractsFiles(t *testing.T) { + uisPath := createTestUisFile(t) + destDir := filepath.Join(t.TempDir(), "output") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", uisPath, "--destination", destDir}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status Succeeded, but got: %v", result.StdOut) + } + if stdout["solutionId"] != "test-solution-id" { + t.Errorf("Expected solutionId test-solution-id, but got: %v", stdout["solutionId"]) + } + + // Verify files were extracted + if _, err := os.Stat(filepath.Join(destDir, "SolutionStorage.json")); err != nil { + t.Errorf("Expected SolutionStorage.json to be extracted, but got error: %v", err) + } + if _, err := os.Stat(filepath.Join(destDir, "Agent", "agent.json")); err != nil { + t.Errorf("Expected Agent/agent.json to be extracted, but got error: %v", err) + } +} + +func TestUnpackReturnsProjectCount(t *testing.T) { + uisPath := createTestUisFile(t) + destDir := filepath.Join(t.TempDir(), "output") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", uisPath, "--destination", destDir}, context) + + stdout := test.ParseOutput(t, result.StdOut) + projectCount, ok := stdout["projectCount"].(float64) + if !ok || projectCount != 1 { + t.Errorf("Expected projectCount 1, but got: %v", stdout["projectCount"]) + } +} + +func TestUnpackZipSlipReturnsError(t *testing.T) { + uisPath := filepath.Join(t.TempDir(), "malicious.uis") + outFile, err := os.Create(uisPath) + if err != nil { + t.Fatalf("Cannot create test .uis file: %v", err) + } + w := zip.NewWriter(outFile) + addZipFile(t, w, "../../etc/passwd", "malicious content") + _ = w.Close() + _ = outFile.Close() + + destDir := filepath.Join(t.TempDir(), "output") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", uisPath, "--destination", destDir}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "is not allowed") { + t.Errorf("Expected zip slip error, but got: %v", result.Error) + } +} + +func TestUnpackExtractsFileContent(t *testing.T) { + uisPath := createTestUisFile(t) + destDir := filepath.Join(t.TempDir(), "output") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", uisPath, "--destination", destDir}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + data, err := os.ReadFile(filepath.Join(destDir, "Agent", "agent.json")) + if err != nil { + t.Fatalf("Expected Agent/agent.json to exist: %v", err) + } + if string(data) != `{"type":"lowCode"}` { + t.Errorf("Expected agent.json content to be preserved, but got: %v", string(data)) + } +} + +func TestUnpackDefaultDestination(t *testing.T) { + uisPath := createTestUisFile(t) + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", uisPath}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + directory, ok := stdout["directory"].(string) + if !ok || directory == "" { + t.Errorf("Expected directory in output, but got: %v", stdout["directory"]) + } +} + +func TestUnpackWithDirectoryEntries(t *testing.T) { + uisPath := filepath.Join(t.TempDir(), "test.uis") + outFile, err := os.Create(uisPath) + if err != nil { + t.Fatalf("Cannot create test .uis file: %v", err) + } + w := zip.NewWriter(outFile) + + // Add explicit directory entry with proper directory mode + dirHeader := &zip.FileHeader{ + Name: "SubDir/", + } + dirHeader.SetMode(os.ModeDir | 0750) + _, err = w.CreateHeader(dirHeader) + if err != nil { + t.Fatalf("Cannot create directory entry: %v", err) + } + addZipFile(t, w, "SubDir/file.txt", "content") + addZipFile(t, w, "SolutionStorage.json", `{"SolutionId":"dir-test"}`) + + _ = w.Close() + _ = outFile.Close() + + destDir := filepath.Join(t.TempDir(), "output") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", uisPath, "--destination", destDir}, context) + + if result.Error != nil { + t.Errorf("Expected no error, but got: %v", result.Error) + } + info, err := os.Stat(filepath.Join(destDir, "SubDir")) + if err != nil || !info.IsDir() { + t.Errorf("Expected SubDir to be extracted as directory") + } + if _, err := os.Stat(filepath.Join(destDir, "SubDir", "file.txt")); err != nil { + t.Errorf("Expected SubDir/file.txt to be extracted: %v", err) + } +} + +func TestUnpackWithoutSolutionStorageJson(t *testing.T) { + uisPath := filepath.Join(t.TempDir(), "test.uis") + outFile, err := os.Create(uisPath) + if err != nil { + t.Fatalf("Cannot create test .uis file: %v", err) + } + w := zip.NewWriter(outFile) + addZipFile(t, w, "Agent/agent.json", `{"type":"lowCode"}`) + _ = w.Close() + _ = outFile.Close() + + destDir := filepath.Join(t.TempDir(), "output") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", uisPath, "--destination", destDir}, context) + + if result.Error != nil { + t.Errorf("Expected no error even without SolutionStorage.json, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["solutionId"] != "" { + t.Errorf("Expected empty solutionId, but got: %v", stdout["solutionId"]) + } + projectCount, ok := stdout["projectCount"].(float64) + if !ok || projectCount != 0 { + t.Errorf("Expected projectCount 0, but got: %v", stdout["projectCount"]) + } +} + +func TestUnpackWithInvalidSolutionStorageJson(t *testing.T) { + uisPath := filepath.Join(t.TempDir(), "test.uis") + outFile, err := os.Create(uisPath) + if err != nil { + t.Fatalf("Cannot create test .uis file: %v", err) + } + w := zip.NewWriter(outFile) + addZipFile(t, w, "SolutionStorage.json", "not valid json") + addZipFile(t, w, "Agent/agent.json", `{"type":"lowCode"}`) + _ = w.Close() + _ = outFile.Close() + + destDir := filepath.Join(t.TempDir(), "output") + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", uisPath, "--destination", destDir}, context) + + if result.Error != nil { + t.Errorf("Expected no error even with invalid JSON, but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["solutionId"] != "" { + t.Errorf("Expected empty solutionId for invalid JSON, but got: %v", stdout["solutionId"]) + } +} + +func TestUnpackToReadOnlyDestinationReturnsError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping permission test on Windows") + } + uisPath := filepath.Join(t.TempDir(), "test.uis") + outFile, err := os.Create(uisPath) + if err != nil { + t.Fatalf("Cannot create test .uis file: %v", err) + } + w := zip.NewWriter(outFile) + addZipFile(t, w, "test.txt", "content") + _ = w.Close() + _ = outFile.Close() + + destDir := filepath.Join(t.TempDir(), "output") + _ = os.MkdirAll(destDir, 0500) + defer func() { _ = os.Chmod(destDir, 0750) }() //nolint:gosec // Restore permissions for test cleanup + + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", uisPath, "--destination", destDir}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "Cannot create file") { + t.Errorf("Expected cannot create file error, but got: %v", result.Error) + } +} + +func TestUnpackSubdirToReadOnlyDestinationReturnsError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping permission test on Windows") + } + uisPath := filepath.Join(t.TempDir(), "test.uis") + outFile, err := os.Create(uisPath) + if err != nil { + t.Fatalf("Cannot create test .uis file: %v", err) + } + w := zip.NewWriter(outFile) + addZipFile(t, w, "SubDir/file.txt", "content") + _ = w.Close() + _ = outFile.Close() + + destDir := filepath.Join(t.TempDir(), "output") + _ = os.MkdirAll(destDir, 0500) + defer func() { _ = os.Chmod(destDir, 0750) }() //nolint:gosec // Restore permissions for test cleanup + + context := test.NewContextBuilder(). + WithDefinition("studio", studio.StudioDefinition). + WithCommandPlugin(NewSolutionUnpackCommand()). + Build() + + result := test.RunCli([]string{"studio", "solution", "unpack", "--source", uisPath, "--destination", destDir}, context) + + if result.Error == nil || !strings.Contains(result.Error.Error(), "Cannot create directory") { + t.Errorf("Expected cannot create directory error, but got: %v", result.Error) + } +} + +func TestUnpackNonStringSourceReturnsError(t *testing.T) { + cmd := NewSolutionUnpackCommand() + ctx := plugin.ExecutionContext{ + Parameters: []plugin.ExecutionParameter{ + {Name: "source", Value: 42}, + }, + } + + err := cmd.Execute(ctx, output.NewMemoryOutputWriter(), log.NewDefaultLogger(io.Discard)) + + if err == nil || err.Error() != "Source .uis file is required" { + t.Errorf("Expected source required error, but got: %v", err) + } +} + +func createTestUisFile(t *testing.T) string { + uisPath := filepath.Join(t.TempDir(), "test.uis") + outFile, err := os.Create(uisPath) + if err != nil { + t.Fatalf("Cannot create test .uis file: %v", err) + } + defer func() { _ = outFile.Close() }() + + w := zip.NewWriter(outFile) + defer func() { _ = w.Close() }() + + addZipFile(t, w, "SolutionStorage.json", `{"SolutionId":"test-solution-id","Projects":[{"ProjectId":"p1","ProjectRelativePath":"Agent/project.uiproj"}]}`) + addZipFile(t, w, "Agent/agent.json", `{"type":"lowCode"}`) + addZipFile(t, w, "Agent/project.uiproj", `{"ProjectType":"Agent"}`) + + return uisPath +} + +func addZipFile(t *testing.T, w *zip.Writer, name string, content string) { + f, err := w.Create(name) + if err != nil { + t.Fatalf("Cannot create zip entry: %v", err) + } + _, err = f.Write([]byte(content)) + if err != nil { + t.Fatalf("Cannot write zip entry: %v", err) + } +} diff --git a/plugin/studio/solution/unpack/solution_unpack_params.go b/plugin/studio/solution/unpack/solution_unpack_params.go new file mode 100644 index 0000000..765d34c --- /dev/null +++ b/plugin/studio/solution/unpack/solution_unpack_params.go @@ -0,0 +1,10 @@ +package unpack + +type solutionUnpackParams struct { + Source string + Destination string +} + +func newSolutionUnpackParams(source string, destination string) *solutionUnpackParams { + return &solutionUnpackParams{source, destination} +} diff --git a/plugin/studio/solution/unpack/solution_unpack_result.go b/plugin/studio/solution/unpack/solution_unpack_result.go new file mode 100644 index 0000000..1a0ec1a --- /dev/null +++ b/plugin/studio/solution/unpack/solution_unpack_result.go @@ -0,0 +1,13 @@ +package unpack + +type solutionUnpackResult struct { + Status string `json:"status"` + Directory string `json:"directory"` + SolutionId string `json:"solutionId"` + ProjectCount int `json:"projectCount"` + Error string `json:"error,omitempty"` +} + +func newSucceededSolutionUnpackResult(directory string, solutionId string, projectCount int) *solutionUnpackResult { + return &solutionUnpackResult{"Succeeded", directory, solutionId, projectCount, ""} +} diff --git a/skill/uipath-studio/SKILL.md b/skill/uipath-studio/SKILL.md new file mode 100644 index 0000000..1a74d93 --- /dev/null +++ b/skill/uipath-studio/SKILL.md @@ -0,0 +1,199 @@ +--- +name: UiPath Studio +description: > + This skill should be used when the user asks to "create a UiPath agent", + "build an agent", "scaffold an agent project", "create a solution", + "pack a solution", "unpack a .uis file", "deploy to UiPath", + "publish a solution", "add a tool to an agent", "create evaluations", + "add an evaluator", "set agent model", "set agent prompt", + "create a coded agent", "create a low-code agent", + "work with UiPath agents", "work with UiPath Maestro", + "create a Maestro project", "create an evaluation set", + "add a web search tool", "configure agent model", + "set up agent evaluations", + "add escalation", "add HITL", "create index", "add context resource", + "validate agent structure", "create UiPath connection", + or mentions UiPath Studio Web, Maestro, .uis files, agent.json, + entry-points.json, project.uiproj, evaluation sets, or the UiPath CLI + studio commands. Provides comprehensive guidance for creating, evaluating, + deploying, and managing UiPath agents and solutions from the command line. +version: 0.1.0 +--- + +# UiPath Studio — Agent & Solution Development + +Create, evaluate, deploy, and manage UiPath agents and Maestro solutions +entirely from the command line. + +## Core Concepts + +### Solutions +A **Solution** is the top-level container in UiPath Maestro. It holds multiple +projects of different types: Agent, Process, WebApp, CaseManagement, +BusinessRules, Connector, ProcessOrchestration, Api. + +A `.uis` file is a ZIP archive containing the entire solution. + +### Agents +Two flavors exist: +- **Low-code** (`type: lowCode`) — visual builder with system/user prompts, + tool bindings, and flow layout +- **Coded** (`type: coded`, `targetRuntime: python`) — Python entry point with + `@traced` decorators, pydantic models, and the `uipath` SDK + +### Evaluations +Every agent supports evals with built-in evaluator types (exact-match, +contains, json-similarity, llm-judge, trajectory) and custom Python evaluators. + +## Workflow: Creating an Agent Solution + +### Step 1: Scaffold the Solution + +Generate `SolutionStorage.json` and `.uipx` manifest. Use the script: + +```bash +bash $SKILL_DIR/scripts/solution_create.sh "MySolution" "Agent" +``` + +Or create manually following `examples/solution/`. + +### Step 2: Scaffold the Agent Project + +For low-code agents, generate the full directory structure with `agent.json`, +`entry-points.json`, `project.uiproj`, and default evals. Reference: +`examples/low-code-agent/` + +For coded agents, also generate `source_code/main.py`, `pyproject.toml`, and +`uipath.json`. Reference: `examples/coded-agent/` + +Key file: `agent.json` — controls model, prompts, I/O schemas, tool bindings. +See `references/agent-structure.md` for all fields. + +### Step 3: Add Tools & Resources + +Add tools to the agent's `resources/` directory. Each tool is a subdirectory +with a `resource.json`. Five tool types exist: + +| Type | resourceType | Use Case | +|------|-------------|----------| +| Integration | `tool` (external) | Web Search, Web Reader, API calls | +| Agent | `tool` (solution) | Agent-calling-agent | +| Internal | `tool` (built-in) | Analyze Files | +| Context | `context` | RAG/Index semantic search | +| Escalation | `escalation` | HITL via Action Center | + +See `references/tool-types.md` for complete schemas and examples. + +### Step 4: Configure Evaluations + +Create eval sets in `evals/eval-sets/` and evaluators in `evals/evaluators/`. +See `references/evaluation-framework.md` for all evaluator types and schemas. + +### Step 5: Pack & Deploy + +**Prerequisite**: Authentication must be configured before deploying. Run +`uipath config --auth login` for interactive setup or configure +`~/.uipath/config` manually. See `references/cli-architecture.md` for details. + +Pack the solution directory into a `.uis` file: + +```bash +bash $SKILL_DIR/scripts/solution_pack.sh ./MySolution MySolution.uis +``` + +Deploy using the UiPath CLI or Studio Web API. See +`references/studio-web-api.md` for deployment endpoints. + +## File Structure Quick Reference + +### Low-Code Agent +``` +Agent/ + agent.json # Model, prompts, settings, I/O schemas + entry-points.json # Entry point definitions + project.uiproj # {ProjectType: "Agent"} + flow-layout.json # Visual layout + .agent-builder/agent.json # Builder metadata with resources + .agent-builder/bindings.json # Connection bindings + .project/JitCustomTypes.json + resources//resource.json + evals/eval-sets/*.json + evals/evaluators/*.json +``` + +### Coded Agent +``` +Agent/ + agent.json # targetRuntime: "python", type: "coded" + entry-points.json + project.uiproj + source_code/main.py # @traced async def main(input) -> Output + source_code/pyproject.toml # uipath>=2.1.87 + source_code/uipath.json + coded-evals/eval-sets/*.json + coded-evals/evaluators/*.json + coded-evals/evaluators/custom/ # Python BaseEvaluator classes +``` + +### Solution Wrapper +``` +/ + SolutionStorage.json # {SolutionId, Projects: [...]} + .uipx # Manifest with project types + Agent/ # Agent project(s) + resources/solution_folder/ # Deployment resources + package/.json # Package resource + process/agent/.json # Process resource + connection/... # Connection resources + index/... # Index resources +``` + +## UiPath CLI Commands + +The CLI binary is `uipath`. Existing studio commands: + +```bash +uipath studio package pack --source [--output-type Process|Library] +uipath studio package publish --source --feed +uipath studio package restore --source +uipath studio package analyze --source [--governance-file ] +uipath studio test run --source [--junit-results ] +``` + +Configuration: `~/.uipath/config` with profiles. Auth via PAT, OAuth, or +client credentials. See `references/cli-architecture.md`. + +## Validation + +Validate an agent project structure before packing: + +```bash +bash $SKILL_DIR/scripts/validate_agent.sh ./Agent +``` + +## Additional Resources + +### Reference Files +- **`references/agent-structure.md`** — Complete agent.json schema, all fields, + model options, prompt templating with contentTokens +- **`references/solution-structure.md`** — Solution manifest, SolutionStorage, + .uipx format, project type registry +- **`references/tool-types.md`** — All 5 tool types with full JSON schemas, + connection bindings, guardrails +- **`references/evaluation-framework.md`** — All evaluator types, eval set + format, custom Python evaluators, coded-evals structure +- **`references/studio-web-api.md`** — Studio Web API endpoints for push/pull, + publish, deploy, debug, file operations, resource builder +- **`references/cli-architecture.md`** — CLI plugin system, how to add new + commands, configuration, authentication + +### Example Files +- **`examples/low-code-agent/`** — Complete low-code agent with Web Search tool +- **`examples/coded-agent/`** — Complete coded Python agent with custom evaluator +- **`examples/solution/`** — Full solution wrapper with deployment resources + +### Scripts +- **`scripts/solution_create.sh`** — Scaffold a new solution +- **`scripts/solution_pack.sh`** — Pack solution directory into .uis +- **`scripts/solution_unpack.sh`** — Unpack .uis into directory +- **`scripts/validate_agent.sh`** — Validate agent project structure diff --git a/skill/uipath-studio/examples/coded-agent/Agent/agent.json b/skill/uipath-studio/examples/coded-agent/Agent/agent.json new file mode 100644 index 0000000..62c2a72 --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/agent.json @@ -0,0 +1,33 @@ +{ + "version": "1.0.0", + "metadata": { + "storageVersion": "27.0.0", + "targetRuntime": "python", + "isConversational": false, + "codeVersion": "1.0.10", + "author": "developer@example.com" + }, + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "max_results": { "type": "integer" } + }, + "required": ["query"] + }, + "outputSchema": { + "type": "object", + "properties": { + "answer": { "type": "string" }, + "sources": { "type": "array", "items": { "type": "string" } } + }, + "required": ["answer"] + }, + "bindings": { + "version": "2.0", + "resources": [] + }, + "settings": {}, + "entryPoints": [{}], + "type": "coded" +} diff --git a/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/eval-sets/default.json b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/eval-sets/default.json new file mode 100644 index 0000000..b3c9553 --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/eval-sets/default.json @@ -0,0 +1,50 @@ +{ + "version": "1.0", + "id": "ResearchEvalSet", + "name": "Research Agent Evaluation Set", + "evaluatorRefs": [ + "ContainsEvaluator", + "ExactMatchEvaluator", + "SemanticSimilarityEvaluator", + "TrajectoryEvaluator" + ], + "evaluations": [ + { + "id": "test-basic-search", + "name": "Basic Search Query", + "inputs": { + "query": "What is UiPath?", + "max_results": 3 + }, + "evaluationCriterias": { + "ContainsEvaluator": { + "searchText": "UiPath" + }, + "SemanticSimilarityEvaluator": { + "expectedOutput": { + "answer": "UiPath is an enterprise automation platform." + } + }, + "TrajectoryEvaluator": { + "expectedAgentBehavior": "The agent should search for UiPath information and return a structured answer with sources." + } + }, + "updatedAt": "2026-01-01T00:00:00.000Z" + }, + { + "id": "test-no-results", + "name": "No Results Handling", + "inputs": { + "query": "xyznonexistent12345", + "max_results": 1 + }, + "evaluationCriterias": { + "ContainsEvaluator": { + "searchText": "No results" + } + } + } + ], + "fileName": "default.json", + "updatedAt": "2026-01-01T00:00:00.000Z" +} diff --git a/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/contains.json b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/contains.json new file mode 100644 index 0000000..ad18cd6 --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/contains.json @@ -0,0 +1,15 @@ +{ + "version": "1.0", + "id": "ContainsEvaluator", + "description": "Checks if the response text includes the expected substring.", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "ContainsEvaluator", + "targetOutputKey": "answer", + "negated": false, + "ignoreCase": true, + "defaultEvaluationCriteria": { + "searchText": "" + } + } +} diff --git a/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/custom/source_counter.py b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/custom/source_counter.py new file mode 100644 index 0000000..536862f --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/custom/source_counter.py @@ -0,0 +1,49 @@ +"""Custom evaluator that checks the number of sources returned.""" + +import json + +from uipath.eval.evaluators import BaseEvaluator, BaseEvaluationCriteria, BaseEvaluatorConfig +from uipath.eval.models import AgentExecution, EvaluationResult, NumericEvaluationResult + + +class SourceCountCriteria(BaseEvaluationCriteria): + """Evaluation criteria for the source count evaluator.""" + + min_sources: int + + +class SourceCountConfig(BaseEvaluatorConfig[SourceCountCriteria]): + """Configuration for the source count evaluator.""" + + name: str = "SourceCountEvaluator" + negated: bool = False + default_evaluation_criteria: SourceCountCriteria = SourceCountCriteria( + min_sources=1 + ) + + +class SourceCountEvaluator( + BaseEvaluator[SourceCountCriteria, SourceCountConfig, type(None)] +): + """Evaluates whether the agent returned enough sources.""" + + @classmethod + def get_evaluator_id(cls) -> str: + return "SourceCountEvaluator" + + async def evaluate( + self, + agent_execution: AgentExecution, + evaluation_criteria: SourceCountCriteria, + ) -> EvaluationResult: + output = agent_execution.output + if isinstance(output, str): + output = json.loads(output) + + sources = output.get("sources", []) + has_enough = len(sources) >= evaluation_criteria.min_sources + + if self.evaluator_config.negated: + has_enough = not has_enough + + return NumericEvaluationResult(score=float(has_enough)) diff --git a/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/custom/types/source-counter-types.json b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/custom/types/source-counter-types.json new file mode 100644 index 0000000..7d59398 --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/custom/types/source-counter-types.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "min_sources": { + "type": "integer", + "description": "Minimum number of sources expected in the output" + } + }, + "required": ["min_sources"] +} diff --git a/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/exact-match.json b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/exact-match.json new file mode 100644 index 0000000..779a839 --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/exact-match.json @@ -0,0 +1,17 @@ +{ + "version": "1.0", + "id": "ExactMatchEvaluator", + "description": "Checks if the response text exactly matches the expected value.", + "evaluatorTypeId": "uipath-exact-match", + "evaluatorConfig": { + "name": "ExactMatchEvaluator", + "targetOutputKey": "answer", + "negated": false, + "ignoreCase": false, + "defaultEvaluationCriteria": { + "expectedOutput": { + "answer": "" + } + } + } +} diff --git a/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/trajectory.json b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/trajectory.json new file mode 100644 index 0000000..b3fe328 --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/trajectory.json @@ -0,0 +1,15 @@ +{ + "version": "1.0", + "id": "TrajectoryEvaluator", + "description": "Evaluates the agent's execution trajectory and decision sequence.", + "evaluatorTypeId": "uipath-llm-judge-trajectory-similarity", + "evaluatorConfig": { + "name": "TrajectoryEvaluator", + "model": "gpt-4.1-2025-04-14", + "prompt": "Evaluate the agent's execution trajectory based on the expected behavior.\n\nExpected Agent Behavior: {{ExpectedAgentBehavior}}\nAgent Run History: {{AgentRunHistory}}\n\nProvide a score from 0-100 based on how well the agent followed the expected trajectory.", + "temperature": 0.0, + "defaultEvaluationCriteria": { + "expectedAgentBehavior": "The agent should correctly perform the task." + } + } +} diff --git a/skill/uipath-studio/examples/coded-agent/Agent/entry-points.json b/skill/uipath-studio/examples/coded-agent/Agent/entry-points.json new file mode 100644 index 0000000..e1a7858 --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/entry-points.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", + "$id": "entry-points.json", + "entryPoints": [ + { + "filePath": "main.py", + "uniqueId": "00000000-0000-0000-0000-000000000050", + "type": "agent", + "input": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "max_results": { "type": "integer" } + }, + "required": ["query"] + }, + "output": { + "type": "object", + "properties": { + "answer": { "type": "string" }, + "sources": { "type": "array", "items": { "type": "string" } } + }, + "required": ["answer"] + } + } + ] +} diff --git a/skill/uipath-studio/examples/coded-agent/Agent/project.uiproj b/skill/uipath-studio/examples/coded-agent/Agent/project.uiproj new file mode 100644 index 0000000..ef40bb8 --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/project.uiproj @@ -0,0 +1,6 @@ +{ + "ProjectType": "Agent", + "Name": "Agent", + "Description": null, + "MainFile": null +} diff --git a/skill/uipath-studio/examples/coded-agent/Agent/source_code/main.py b/skill/uipath-studio/examples/coded-agent/Agent/source_code/main.py new file mode 100644 index 0000000..df62f41 --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/source_code/main.py @@ -0,0 +1,78 @@ +"""UiPath Coded Agent — Research Assistant. + +This agent processes natural language queries and returns structured answers. +Uses the UiPath Python SDK with tracing and evaluation support. +""" + +import logging +from typing import Optional + +from pydantic.dataclasses import dataclass +from uipath.eval.mocks import ExampleCall, mockable +from uipath.tracing import traced + +logger = logging.getLogger(__name__) + + +@dataclass +class ResearchInput: + """Agent input schema.""" + + query: str + max_results: Optional[int] = 5 + + +@dataclass +class ResearchOutput: + """Agent output schema.""" + + answer: str + sources: list[str] + + +# Example calls for evaluation simulation +SEARCH_EXAMPLES = [ + ExampleCall( + id="search-example", + input='{"query": "What is UiPath?"}', + output='{"results": [{"title": "UiPath", "url": "https://uipath.com", "snippet": "Enterprise automation platform"}]}', + ) +] + + +@traced() +@mockable(example_calls=SEARCH_EXAMPLES) +async def search_web(query: str) -> dict: + """Search the web for information. + + In production, this calls UiPath GenAI Activities web search. + During evals, returns mock data from SEARCH_EXAMPLES. + """ + # This would be replaced with actual UiPath tool call + return {"results": []} + + +@traced(name="format_answer") +def format_answer(query: str, search_results: list[dict]) -> ResearchOutput: + """Format search results into a structured answer.""" + sources = [r.get("url", "") for r in search_results if r.get("url")] + snippets = [r.get("snippet", "") for r in search_results if r.get("snippet")] + answer = f"Results for '{query}': " + " | ".join(snippets) if snippets else "No results found." + return ResearchOutput(answer=answer, sources=sources) + + +@traced() +async def main(input: ResearchInput) -> ResearchOutput: + """Main agent entry point. + + Searches the web for the query and returns a formatted answer. + """ + logger.info("Processing query: %s", input.query) + + search_data = await search_web(input.query) + results = search_data.get("results", []) + + if input.max_results: + results = results[: input.max_results] + + return format_answer(input.query, results) diff --git a/skill/uipath-studio/examples/coded-agent/Agent/source_code/pyproject.toml b/skill/uipath-studio/examples/coded-agent/Agent/source_code/pyproject.toml new file mode 100644 index 0000000..3bdb616 --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/source_code/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "research-agent" +version = "0.0.1" +description = "A research agent that searches the web and provides structured answers" +authors = [{ name = "Developer", email = "developer@example.com" }] +dependencies = ["uipath>=2.1.87"] +requires-python = ">=3.10" diff --git a/skill/uipath-studio/examples/coded-agent/Agent/source_code/uipath.json b/skill/uipath-studio/examples/coded-agent/Agent/source_code/uipath.json new file mode 100644 index 0000000..2a4fb62 --- /dev/null +++ b/skill/uipath-studio/examples/coded-agent/Agent/source_code/uipath.json @@ -0,0 +1,29 @@ +{ + "entryPoints": [ + { + "filePath": "main.py", + "uniqueId": "00000000-0000-0000-0000-000000000050", + "type": "agent", + "input": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "max_results": { "type": "integer" } + }, + "required": ["query"] + }, + "output": { + "type": "object", + "properties": { + "answer": { "type": "string" }, + "sources": { "type": "array", "items": { "type": "string" } } + }, + "required": ["answer"] + } + } + ], + "bindings": { + "version": "2.0", + "resources": [] + } +} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/agent.json b/skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/agent.json new file mode 100644 index 0000000..12b42bd --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/agent.json @@ -0,0 +1,53 @@ +{ + "id": "00000000-0000-0000-0000-000000000001", + "version": "1.1.0", + "name": "Agent", + "metadata": { + "storageVersion": "44.0.0", + "isConversational": false, + "showProjectCreationExperience": true + }, + "messages": [ + { + "role": "system", + "content": "You are a research assistant that searches the web to answer queries comprehensively.", + "contentTokens": [ + { + "type": "simpleText", + "rawString": "You are a research assistant that searches the web to answer queries comprehensively." + } + ] + }, + { + "role": "user", + "content": "query: {{query}}", + "contentTokens": [ + { "type": "simpleText", "rawString": "query: " }, + { "type": "variable", "rawString": "input.query" }, + { "type": "simpleText", "rawString": "" } + ] + } + ], + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" } + }, + "required": ["query"] + }, + "outputSchema": { + "type": "object", + "properties": { + "content": { "type": "string", "description": "Output content" } + } + }, + "settings": { + "model": "anthropic.claude-haiku-4-5-20251001-v1:0", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + "maxIterations": 25 + }, + "resources": [], + "features": [] +} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/bindings.json b/skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/bindings.json new file mode 100644 index 0000000..5e9beeb --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/bindings.json @@ -0,0 +1,4 @@ +{ + "version": "2.0", + "resources": [] +} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/entry-points.json b/skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/entry-points.json new file mode 100644 index 0000000..c9d6a89 --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/entry-points.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", + "$id": "entry-points.json", + "entryPoints": [ + { + "uniqueId": "00000000-0000-0000-0000-000000000010", + "type": "agent", + "input": { + "type": "object", + "properties": { + "query": { "type": "string" } + }, + "required": ["query"] + }, + "output": { + "type": "object", + "properties": { + "content": { "type": "string", "description": "Output content" } + } + } + } + ] +} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/.project/JitCustomTypes.json b/skill/uipath-studio/examples/low-code-agent/Agent/.project/JitCustomTypes.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/.project/JitCustomTypes.json @@ -0,0 +1 @@ +{} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/agent.json b/skill/uipath-studio/examples/low-code-agent/Agent/agent.json new file mode 100644 index 0000000..0b3dd80 --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/agent.json @@ -0,0 +1,57 @@ +{ + "version": "1.1.0", + "settings": { + "model": "anthropic.claude-haiku-4-5-20251001-v1:0", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + "maxIterations": 25 + }, + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": ["query"] + }, + "outputSchema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Output content" + } + } + }, + "metadata": { + "storageVersion": "44.0.0", + "isConversational": false, + "showProjectCreationExperience": true, + "targetRuntime": "pythonAgent" + }, + "type": "lowCode", + "projectId": "00000000-0000-0000-0000-000000000001", + "messages": [ + { + "role": "system", + "content": "You are a research assistant that searches the web to answer queries comprehensively.", + "contentTokens": [ + { + "type": "simpleText", + "rawString": "You are a research assistant that searches the web to answer queries comprehensively." + } + ] + }, + { + "role": "user", + "content": "query: {{query}}", + "contentTokens": [ + { "type": "simpleText", "rawString": "query: " }, + { "type": "variable", "rawString": "input.query" }, + { "type": "simpleText", "rawString": "" } + ] + } + ] +} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/entry-points.json b/skill/uipath-studio/examples/low-code-agent/Agent/entry-points.json new file mode 100644 index 0000000..c9d6a89 --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/entry-points.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", + "$id": "entry-points.json", + "entryPoints": [ + { + "uniqueId": "00000000-0000-0000-0000-000000000010", + "type": "agent", + "input": { + "type": "object", + "properties": { + "query": { "type": "string" } + }, + "required": ["query"] + }, + "output": { + "type": "object", + "properties": { + "content": { "type": "string", "description": "Output content" } + } + } + } + ] +} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/evals/eval-sets/evaluation-set-default.json b/skill/uipath-studio/examples/low-code-agent/Agent/evals/eval-sets/evaluation-set-default.json new file mode 100644 index 0000000..765f136 --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/evals/eval-sets/evaluation-set-default.json @@ -0,0 +1,29 @@ +{ + "fileName": "evaluation-set-default.json", + "id": "00000000-0000-0000-0000-000000000030", + "name": "Default Evaluation Set", + "batchSize": 10, + "evaluatorRefs": ["00000000-0000-0000-0000-000000000040"], + "evaluations": [ + { + "id": "00000000-0000-0000-0000-000000000031", + "name": "Basic search test", + "inputs": { + "query": "What is UiPath?" + }, + "expectedOutput": { + "content": "UiPath is a leading enterprise automation platform." + }, + "simulationInstructions": "", + "expectedAgentBehavior": "The agent should use web search to find information about UiPath and provide a comprehensive summary.", + "simulateInput": false, + "inputGenerationInstructions": "", + "simulateTools": false, + "toolsToSimulate": [], + "evalSetId": "00000000-0000-0000-0000-000000000030", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z", + "source": "manual" + } + ] +} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/evals/evaluators/evaluator-default-trajectory.json b/skill/uipath-studio/examples/low-code-agent/Agent/evals/evaluators/evaluator-default-trajectory.json new file mode 100644 index 0000000..9a56e02 --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/evals/evaluators/evaluator-default-trajectory.json @@ -0,0 +1,15 @@ +{ + "version": "1.0", + "id": "00000000-0000-0000-0000-000000000041", + "description": "Evaluates the agent's execution trajectory and decision sequence.", + "evaluatorTypeId": "uipath-llm-judge-trajectory-similarity", + "evaluatorConfig": { + "name": "TrajectoryEvaluator", + "model": "gpt-4.1-2025-04-14", + "prompt": "Evaluate the agent's execution trajectory based on the expected behavior.\n\nExpected Agent Behavior: {{ExpectedAgentBehavior}}\nAgent Run History: {{AgentRunHistory}}\n\nProvide a score from 0-100 based on how well the agent followed the expected trajectory.", + "temperature": 0.0, + "defaultEvaluationCriteria": { + "expectedAgentBehavior": "The agent should correctly perform the task." + } + } +} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/evals/evaluators/evaluator-default.json b/skill/uipath-studio/examples/low-code-agent/Agent/evals/evaluators/evaluator-default.json new file mode 100644 index 0000000..4ed8915 --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/evals/evaluators/evaluator-default.json @@ -0,0 +1,18 @@ +{ + "version": "1.0", + "id": "00000000-0000-0000-0000-000000000040", + "description": "Uses an LLM to judge semantic similarity between expected and actual output.", + "evaluatorTypeId": "uipath-llm-judge-output-semantic-similarity", + "evaluatorConfig": { + "name": "SemanticSimilarityEvaluator", + "targetOutputKey": "*", + "model": "gpt-4.1-2025-04-14", + "prompt": "Compare the following outputs and evaluate their semantic similarity.\n\nActual Output: {{ActualOutput}}\nExpected Output: {{ExpectedOutput}}\n\nProvide a score from 0-100 where 100 means semantically identical and 0 means completely different.", + "temperature": 0.0, + "defaultEvaluationCriteria": { + "expectedOutput": { + "content": "" + } + } + } +} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/flow-layout.json b/skill/uipath-studio/examples/low-code-agent/Agent/flow-layout.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/flow-layout.json @@ -0,0 +1 @@ +{} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/project.uiproj b/skill/uipath-studio/examples/low-code-agent/Agent/project.uiproj new file mode 100644 index 0000000..ef40bb8 --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/project.uiproj @@ -0,0 +1,6 @@ +{ + "ProjectType": "Agent", + "Name": "Agent", + "Description": null, + "MainFile": null +} diff --git a/skill/uipath-studio/examples/low-code-agent/Agent/resources/Web Search/resource.json b/skill/uipath-studio/examples/low-code-agent/Agent/resources/Web Search/resource.json new file mode 100644 index 0000000..c5efd8e --- /dev/null +++ b/skill/uipath-studio/examples/low-code-agent/Agent/resources/Web Search/resource.json @@ -0,0 +1,109 @@ +{ + "$resourceType": "tool", + "name": "Web Search", + "description": "Web search executes a search of the public domain using a natural language search query.", + "location": "external", + "type": "integration", + "inputSchema": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "title": "Search Engine", + "enum": ["GoogleCustomSearch"], + "oneOf": [{ "const": "GoogleCustomSearch", "title": "GoogleCustomSearch" }] + }, + "query": { + "type": "string", + "title": "Search", + "description": "The natural language query to search the web for" + }, + "num": { + "type": "integer", + "title": "Number of results", + "description": "The number of results. Default to 10." + } + }, + "required": ["provider", "query"] + }, + "outputSchema": { + "type": "object", + "properties": { + "results[*]": { + "type": "array", + "title": "Results", + "items": { "$ref": "#/definitions/results[*]" } + }, + "formattedResults": { + "type": "string", + "title": "Formatted results" + } + }, + "definitions": { + "results[*]": { + "type": "object", + "properties": { + "title": { "type": "string", "title": "Result title" }, + "snippet": { "type": "string", "title": "Result snippet" }, + "url": { "type": "string", "title": "Result url" } + } + } + } + }, + "settings": {}, + "properties": { + "toolPath": "/v2/webSearch", + "objectName": "v2::webSearch", + "toolDisplayName": "Web Search", + "method": "POST", + "connection": { + "id": "", + "name": "", + "state": "enabled", + "connector": { + "key": "uipath-uipath-airdk", + "name": "UiPath GenAI Activities", + "enabled": true + }, + "folder": { + "key": "00000000-0000-0000-0000-000000000000", + "path": "" + }, + "solutionProperties": { + "resourceKey": "00000000-0000-0000-0000-000000000100" + } + }, + "parameters": [ + { + "name": "provider", + "displayName": "Search Engine", + "type": "string", + "fieldLocation": "body", + "fieldVariant": "static", + "value": "GoogleCustomSearch", + "dynamic": false, + "position": "primary", + "sortOrder": 1, + "required": true, + "enumValues": [{ "name": "GoogleCustomSearch", "value": "GoogleCustomSearch" }] + }, + { + "name": "query", + "displayName": "Search", + "type": "string", + "fieldLocation": "body", + "fieldVariant": "dynamic", + "value": "{{prompt}}", + "dynamic": true, + "position": "primary", + "sortOrder": 2, + "required": true + } + ], + "bodyStructure": { "contentType": "json" } + }, + "guardrail": { "policies": [] }, + "id": "00000000-0000-0000-0000-000000000020", + "isPreview": false, + "isEnabled": true +} diff --git a/skill/uipath-studio/examples/solution/MySolution.uipx b/skill/uipath-studio/examples/solution/MySolution.uipx new file mode 100644 index 0000000..9bd8393 --- /dev/null +++ b/skill/uipath-studio/examples/solution/MySolution.uipx @@ -0,0 +1,12 @@ +{ + "DocVersion": "1.0.0", + "StudioMinVersion": "2025.04.0", + "SolutionId": "00000000-0000-0000-0000-000000000060", + "Projects": [ + { + "Type": "Agent", + "ProjectRelativePath": "Agent/project.uiproj", + "Id": "00000000-0000-0000-0000-000000000061" + } + ] +} diff --git a/skill/uipath-studio/examples/solution/SolutionStorage.json b/skill/uipath-studio/examples/solution/SolutionStorage.json new file mode 100644 index 0000000..09f2bdc --- /dev/null +++ b/skill/uipath-studio/examples/solution/SolutionStorage.json @@ -0,0 +1,9 @@ +{ + "SolutionId": "00000000-0000-0000-0000-000000000060", + "Projects": [ + { + "ProjectId": "00000000-0000-0000-0000-000000000001", + "ProjectRelativePath": "Agent/project.uiproj" + } + ] +} diff --git a/skill/uipath-studio/examples/solution/resources/solution_folder/package/Agent.json b/skill/uipath-studio/examples/solution/resources/solution_folder/package/Agent.json new file mode 100644 index 0000000..c67b05c --- /dev/null +++ b/skill/uipath-studio/examples/solution/resources/solution_folder/package/Agent.json @@ -0,0 +1,21 @@ +{ + "docVersion": "1.0.0", + "resource": { + "name": "Agent", + "kind": "package", + "apiVersion": "orchestrator.uipath.com/v1", + "projectKey": "00000000-0000-0000-0000-000000000061", + "dependencies": [], + "runtimeDependencies": [], + "files": [], + "folders": [{ "fullyQualifiedName": "solution_folder" }], + "spec": { + "fileName": null, + "fileReference": null, + "name": "Agent", + "description": null + }, + "locks": [], + "key": "00000000-0000-0000-0000-000000000070" + } +} diff --git a/skill/uipath-studio/examples/solution/resources/solution_folder/process/agent/Agent.json b/skill/uipath-studio/examples/solution/resources/solution_folder/process/agent/Agent.json new file mode 100644 index 0000000..861cff6 --- /dev/null +++ b/skill/uipath-studio/examples/solution/resources/solution_folder/process/agent/Agent.json @@ -0,0 +1,39 @@ +{ + "docVersion": "1.0.0", + "resource": { + "name": "Agent", + "kind": "process", + "type": "agent", + "apiVersion": "orchestrator.uipath.com/v1", + "projectKey": "00000000-0000-0000-0000-000000000061", + "dependencies": [ + { "name": "Agent", "kind": "package" } + ], + "runtimeDependencies": [], + "files": [], + "folders": [{ "fullyQualifiedName": "solution_folder" }], + "spec": { + "entryPointUniqueId": null, + "type": "Agent", + "name": "Agent", + "description": null, + "package": { "key": "00000000-0000-0000-0000-000000000070" }, + "packageName": "MySolution.agent.Agent", + "packageVersion": null, + "entryPointName": null, + "inputArguments": "{}", + "hiddenForAttendedUser": false, + "alwaysRunning": false, + "autoStartProcess": false, + "targetFrameworkValue": "Portable", + "agentMemory": false, + "retentionAction": "Delete", + "retentionPeriod": 30, + "staleRetentionAction": "Delete", + "staleRetentionPeriod": 180, + "tags": [] + }, + "locks": [], + "key": "00000000-0000-0000-0000-000000000071" + } +} diff --git a/skill/uipath-studio/references/agent-structure.md b/skill/uipath-studio/references/agent-structure.md new file mode 100644 index 0000000..953e748 --- /dev/null +++ b/skill/uipath-studio/references/agent-structure.md @@ -0,0 +1,330 @@ +# Agent Structure Reference + +Complete specification of all files in a UiPath agent project. + +## agent.json — Core Agent Definition + +The central file controlling agent behavior, model, prompts, and I/O. + +### Low-Code Agent + +```json +{ + "version": "1.1.0", + "settings": { + "model": "anthropic.claude-haiku-4-5-20251001-v1:0", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + "maxIterations": 25 + }, + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" } + }, + "required": ["query"] + }, + "outputSchema": { + "type": "object", + "properties": { + "content": { "type": "string", "description": "Output content" } + } + }, + "metadata": { + "storageVersion": "44.0.0", + "isConversational": false, + "showProjectCreationExperience": true, + "targetRuntime": "pythonAgent" + }, + "type": "lowCode", + "projectId": "", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant.", + "contentTokens": [ + { "type": "simpleText", "rawString": "You are a helpful assistant." } + ] + }, + { + "role": "user", + "content": "query: {{query}}", + "contentTokens": [ + { "type": "simpleText", "rawString": "query: " }, + { "type": "variable", "rawString": "input.query" }, + { "type": "simpleText", "rawString": "" } + ] + } + ] +} +``` + +### Coded Agent + +```json +{ + "version": "1.0.0", + "metadata": { + "storageVersion": "27.0.0", + "targetRuntime": "python", + "isConversational": false, + "codeVersion": "1.0.10", + "author": "user@example.com", + "pushDate": "2025-10-24T20:00:07.198305+00:00" + }, + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" } + }, + "required": ["query"] + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { "type": "string" } + }, + "required": ["result"] + }, + "bindings": { + "version": "2.0", + "resources": [] + }, + "settings": {}, + "entryPoints": [{}], + "type": "coded" +} +``` + +### Field Reference + +| Field | Type | Description | +|-------|------|-------------| +| `version` | string | Schema version. `"1.1.0"` for low-code, `"1.0.0"` for coded | +| `type` | string | `"lowCode"` or `"coded"` | +| `settings.model` | string | LLM model identifier | +| `settings.maxTokens` | integer | Maximum output tokens (default: 16384) | +| `settings.temperature` | number | Sampling temperature (0-1, default: 0) | +| `settings.engine` | string | Agent engine version (default: `"basic-v2"`) | +| `settings.maxIterations` | integer | Maximum tool-use iterations (default: 25) | +| `inputSchema` | object | JSON Schema for agent inputs | +| `outputSchema` | object | JSON Schema for agent outputs | +| `messages` | array | System and user prompt messages (low-code only) | +| `metadata.storageVersion` | string | Internal storage version | +| `metadata.targetRuntime` | string | `"pythonAgent"` (low-code) or `"python"` (coded) | +| `metadata.isConversational` | boolean | Whether agent maintains conversation state | +| `metadata.codeVersion` | string | Code version (coded agents only) | +| `projectId` | string | UUID linking to project (low-code only) | +| `bindings` | object | Resource/connection bindings (coded agents) | + +### Supported Models + +| Model ID | Provider | +|----------|----------| +| `gpt-4o-2024-11-20` | OpenAI | +| `gpt-4.1-2025-04-14` | OpenAI | +| `anthropic.claude-haiku-4-5-20251001-v1:0` | Anthropic | +| `anthropic.claude-sonnet-4-20250514-v1:0` | Anthropic | + +### Message Content Tokens + +Messages use `contentTokens` for variable interpolation: + +| Token Type | Description | Example | +|-----------|-------------|---------| +| `simpleText` | Static text | `{"type":"simpleText","rawString":"Hello "}` | +| `variable` | Input variable reference | `{"type":"variable","rawString":"input.query"}` | + +The `content` field contains the rendered template with `{{variableName}}` +placeholders. The `contentTokens` array provides the structured representation. + +### Input Schema Special Types + +For file/attachment inputs, use the `job-attachment` type: + +```json +{ + "type": "object", + "properties": { + "file": { "$ref": "#/definitions/job-attachment" } + }, + "definitions": { + "job-attachment": { + "type": "object", + "required": ["ID"], + "x-uipath-resource-kind": "JobAttachment", + "properties": { + "ID": { "type": "string", "description": "Orchestrator attachment key" }, + "FullName": { "type": "string", "description": "File name" }, + "MimeType": { "type": "string", "description": "MIME type" }, + "Metadata": { + "type": "object", + "description": "Dictionary of metadata", + "additionalProperties": { "type": "string" } + } + } + } + } +} +``` + +## entry-points.json + +Defines the agent's callable entry points with input/output schemas. + +```json +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", + "$id": "entry-points.json", + "entryPoints": [ + { + "filePath": "main.py", + "uniqueId": "", + "type": "agent", + "input": { + "type": "object", + "properties": { + "query": { "type": "string" } + }, + "required": ["query"] + }, + "output": { + "type": "object", + "properties": { + "result": { "type": "string" } + }, + "required": ["result"] + } + } + ] +} +``` + +For low-code agents, `filePath` is omitted. For coded agents, it points to the +Python entry point (e.g., `main.py`). + +## project.uiproj + +Minimal project descriptor: + +```json +{ + "ProjectType": "Agent", + "Name": "Agent", + "Description": null, + "MainFile": null +} +``` + +## .agent-builder/ (Low-Code Only) + +### agent.json +Extended version of the top-level `agent.json` that includes full resource +definitions inline (with `inputSchema`, `outputSchema`, connection properties). +Contains `id` field matching `projectId`. + +### bindings.json +Connection bindings for external tools: + +```json +{ + "version": "2.0", + "resources": [ + { + "resource": "connection", + "key": "", + "value": { + "connectionId": { + "defaultValue": "", + "isExpression": false, + "displayName": "Connection ID" + } + }, + "metadata": { + "connector": "uipath-uipath-airdk", + "useConnectionService": "true", + "bindingsVersion": "2.2", + "solutionsSupport": "true" + } + } + ] +} +``` + +### entry-points.json +Same structure as top-level `entry-points.json`. + +## source_code/ (Coded Agents Only) + +### main.py + +```python +import logging +from pydantic.dataclasses import dataclass +from uipath.tracing import traced + +logger = logging.getLogger(__name__) + +@dataclass +class AgentInput: + query: str + +@dataclass +class AgentOutput: + result: str + +@traced() +async def main(input: AgentInput) -> AgentOutput: + # Agent logic here + return AgentOutput(result=f"Processed: {input.query}") +``` + +Key patterns: +- Use `@dataclass` from `pydantic.dataclasses` for I/O types +- Use `@traced()` decorator for observability +- The `main` function is the entry point — must be async +- Use `@mockable(example_calls=...)` for tool simulation in evals + +### pyproject.toml + +```toml +[project] +name = "my-agent" +version = "0.0.1" +description = "My agent description" +authors = [{ name = "Author", email = "author@example.com" }] +dependencies = ["uipath>=2.1.87"] +requires-python = ">=3.10" +``` + +### uipath.json + +Runtime configuration matching the entry-points.json schema: + +```json +{ + "entryPoints": [ + { + "filePath": "main.py", + "uniqueId": "", + "type": "agent", + "input": { ... }, + "output": { ... } + } + ], + "bindings": { + "version": "2.0", + "resources": [] + } +} +``` + +## flow-layout.json (Low-Code Only) + +Visual layout metadata for the agent builder UI. Auto-generated. +Typically an empty object `{}` or contains node positioning data. + +## .project/JitCustomTypes.json + +JIT compilation custom types. Usually empty: `{}` diff --git a/skill/uipath-studio/references/cli-architecture.md b/skill/uipath-studio/references/cli-architecture.md new file mode 100644 index 0000000..3635177 --- /dev/null +++ b/skill/uipath-studio/references/cli-architecture.md @@ -0,0 +1,268 @@ +# UiPath CLI Architecture Reference + +How the UiPath CLI (`uipathcli`) is structured and how to extend it. + +## Overview + +The CLI is written in Go and uses two execution models: +1. **OpenAPI-generated commands** — auto-generated from YAML definitions +2. **Plugin commands** — hand-crafted Go code for complex operations + +## Directory Structure + +``` +uipathcli/ + main.go # Entry point, registers plugins + definitions/ # Embedded OpenAPI YAML specs + orchestrator.yaml # Orchestrator API (45K lines) + du.framework.yaml # Document Understanding API + identity.yaml # Identity Server API + identity.token.yaml # Token endpoint + studio.yaml # Studio (currently empty) + plugin/ # Plugin command implementations + command_plugin.go # CommandPlugin interface + command.go # Command metadata struct + execution_context.go # Plugin execution context + digitizer/ # DU digitize command + orchestrator/ + download/ # Bucket download + upload/ # Bucket upload + studio/ + pack/ # Package pack + publish/ # Package publish + analyze/ # Package analyze + restore/ # Package restore + testrun/ # Test run + commandline/ # CLI framework + cli.go # Main CLI runner + command_builder.go # Command tree builder + definition_provider.go # Definition loading + executor/ # Execution engines + http_executor.go # For OpenAPI commands + plugin_executor.go # For plugin commands + auth/ # Authentication + pat_authenticator.go # Personal Access Token + oauth_authenticator.go # Browser-based OAuth + bearer_authenticator.go # Client credentials + config/ # Configuration management + parser/ # OpenAPI parser + output/ # Response formatting +``` + +## Plugin Interface + +To add a new command, implement `plugin.CommandPlugin`: + +```go +type CommandPlugin interface { + Command() Command + Execute(ctx ExecutionContext, writer output.OutputWriter, logger log.Logger) error +} +``` + +### Command Metadata + +```go +func NewCommand(service string) *Command + +cmd := plugin.NewCommand("studio"). + WithCategory("solution", "Solution management", "Manage UiPath solutions"). + WithOperation("pack", "Pack solution", "Package solution directory into .uis"). + WithParameter(plugin.NewParameter("source", plugin.ParameterTypeString, "Source directory", true)). + WithParameter(plugin.NewParameter("output", plugin.ParameterTypeString, "Output .uis file", false)) +``` + +### Parameter Types + +| Type | Constant | +|------|----------| +| String | `plugin.ParameterTypeString` | +| Integer | `plugin.ParameterTypeInteger` | +| Boolean | `plugin.ParameterTypeBoolean` | +| Binary (file) | `plugin.ParameterTypeBinary` | +| String Array | `plugin.ParameterTypeStringArray` | + +### Execution Context + +```go +type ExecutionContext struct { + Organization string + Tenant string + BaseUri url.URL + Auth AuthToken + Parameters []ExecutionParameter + Debug bool + Settings map[string]interface{} +} +``` + +Access parameters: +```go +source := ctx.Parameters.Get("source") // returns string value +``` + +### Registration in main.go + +```go +cli := commandline.NewCli( + // ... + *commandline.NewDefinitionProvider( + // ... + []plugin.CommandPlugin{ + // Existing plugins + plugin_studio_pack.NewPackagePackCommand(), + // New plugins + plugin_studio_solution.NewSolutionPackCommand(), + plugin_studio_solution.NewSolutionUnpackCommand(), + plugin_studio_agent.NewAgentInitCommand(), + }, + ), + // ... +) +``` + +## Configuration + +### Config File: `~/.uipath/config` + +```yaml +profiles: + - name: default + organization: my-org + tenant: my-tenant + uri: https://cloud.uipath.com + auth: + clientId: + clientSecret: + header: {} + parameter: {} + - name: alpha + organization: my-org + tenant: my-tenant + uri: https://alpha.uipath.com + auth: + properties: + - grantType: authorization_code +``` + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `UIPATH_PROFILE` | Active profile name | +| `UIPATH_ORGANIZATION` | Organization override | +| `UIPATH_TENANT` | Tenant override | +| `UIPATH_CLIENT_ID` | Client ID override | +| `UIPATH_CLIENT_SECRET` | Client secret override | +| `UIPATH_PAT` | Personal Access Token | +| `UIPATH_URI` | Base URI override | +| `UIPATH_OUTPUT` | Output format (json/text) | +| `UIPATH_DEBUG` | Enable debug logging | +| `UIPATH_INSECURE` | Skip TLS verification | +| `UIPATH_CONFIGURATION_PATH` | Config file path | +| `UIPATH_DEFINITIONS_PATH` | Definitions directory path | + +### Authentication Methods + +1. **PAT** — `UIPATH_PAT` env var or `auth.pat` in config +2. **OAuth** — Browser-based login, caches tokens in `~/.uipath/cache/` +3. **Client Credentials** — `clientId` + `clientSecret` → Bearer token +4. **Login** — `auth.properties.grantType: authorization_code` + +Auth is tried in order: PAT → OAuth → Bearer. First success wins. + +## Existing CLI Commands + +### Studio Package Commands +```bash +uipath studio package pack --source [--output-type Process|Library] [--auto-version] [--output ] +uipath studio package publish --source [--organization-feed] [--tenant-feed] +uipath studio package restore --source +uipath studio package analyze --source [--governance-file ] [--treat-warnings-as-errors] +uipath studio test run --source [--junit-results ] [--uipath-results ] [--attach-robot-logs] +``` + +### Orchestrator Commands +```bash +uipath orchestrator buckets upload --folder-id --bucket-id --path --file +uipath orchestrator buckets download --folder-id --bucket-id --path +uipath orchestrator jobs start-jobs --folder-id --start-info "..." +uipath orchestrator releases get --folder-id +uipath orchestrator processes upload-package --folder-id --file +``` + +### Document Understanding Commands +```bash +uipath du digitization digitize --project-id --file +uipath du extraction extract --project-id --document-id --extractor-id +uipath du classification classify --project-id --document-id --classifier-id +``` + +### Configuration Commands +```bash +uipath config # Interactive configuration +uipath config --auth login # OAuth browser login +uipath config --auth credentials # Client credentials setup +uipath config --auth pat # PAT setup +``` + +### Common Flags +```bash +--profile # Select profile +--output json|text # Output format +--query # JMESPath query on output +--uri # Override base URI +--debug # Show HTTP request/response +--insecure # Skip TLS verification +--wait # Wait for async operations +--wait-timeout # Wait timeout +--file @ # Read file as input +``` + +## Adding OpenAPI-Generated Commands + +To add auto-generated commands, populate the YAML definition file: + +1. Download the Swagger spec: + ```bash + curl -o swagger.json https://alpha.uipath.com/{org}/studio_/backend/swagger/v1/swagger.json + ``` + +2. Convert to OpenAPI 3.0 YAML (if needed) + +3. Place in `definitions/studio.yaml` (or create `definitions/studio.web.yaml`) + +4. Custom parameter naming via `x-uipathcli-name` extension: + ```yaml + parameters: + - name: X-UIPATH-OrganizationUnitId + x-uipathcli-name: folder-id + ``` + +5. Custom operation naming via `x-uipathcli-name` extension on operations + +The parser automatically generates CLI commands from the OpenAPI paths. + +## URL Construction + +Default: `https://cloud.uipath.com/{organization}/{tenant}/{service}_/...` + +Services: `orchestrator_`, `du_`, `studio_`, `identity_` + +For alpha: `https://alpha.uipath.com/{organization}/{tenant}/{service}_/...` + +Override with `--uri` flag or `uri` in profile config. + +## Output Transformation + +Use JMESPath queries to transform output: + +```bash +uipath orchestrator releases get --folder-id 123 --query "value[].{Name:Name,Key:Key}" +``` + +Text output is tab-separated for Unix tool compatibility: + +```bash +uipath orchestrator folders get --output text | awk -F'\t' '{print $2}' +``` diff --git a/skill/uipath-studio/references/evaluation-framework.md b/skill/uipath-studio/references/evaluation-framework.md new file mode 100644 index 0000000..480da9f --- /dev/null +++ b/skill/uipath-studio/references/evaluation-framework.md @@ -0,0 +1,407 @@ +# Evaluation Framework Reference + +Complete specification of UiPath agent evaluation system. + +## Directory Structure + +### Low-Code Agents + +``` +Agent/evals/ + eval-sets/ + evaluation-set-default.json + evaluators/ + evaluator-default.json + evaluator-default-trajectory.json +``` + +### Coded Agents + +``` +Agent/coded-evals/ + eval-sets/ + default.json + evaluators/ + exact-match.json + contains.json + json-similarity.json + llm-judge-semantic-similarity.json + llm-judge-strict-json-similarity.json + trajectory.json + custom/ + my_evaluator.py + types/ + my-evaluator-types.json +Agent/evals/ + eval-sets/ + evaluation-set-default.json + evaluators/ + evaluator-default.json + evaluator-default-trajectory.json +``` + +Coded agents have BOTH `coded-evals/` (Python SDK format) and `evals/` +(platform format). The `coded-evals/` directory uses the Python evaluator SDK. + +## Evaluation Sets + +### Low-Code Format + +`evals/eval-sets/evaluation-set-default.json`: + +```json +{ + "fileName": "evaluation-set-default.json", + "id": "", + "name": "Default Evaluation Set", + "batchSize": 10, + "evaluatorRefs": [""], + "evaluations": [ + { + "id": "", + "name": "Test Case Name", + "inputs": { + "query": "What is UiPath?" + }, + "expectedOutput": { + "content": "UiPath is a leading enterprise automation platform..." + }, + "simulationInstructions": "", + "expectedAgentBehavior": "The agent should search the web and provide a summary.", + "simulateInput": false, + "inputGenerationInstructions": "", + "simulateTools": false, + "toolsToSimulate": [], + "evalSetId": "", + "createdAt": "2026-01-22T18:47:25.622Z", + "updatedAt": "2026-01-22T18:50:44.767Z", + "source": "manual" + } + ] +} +``` + +### Coded Format + +`coded-evals/eval-sets/default.json`: + +```json +{ + "version": "1.0", + "id": "MyEvalSet", + "name": "My Evaluation Set", + "evaluatorRefs": [ + "ContainsEvaluator", + "ExactMatchEvaluator", + "LLMJudgeOutputEvaluator", + "TrajectoryEvaluator" + ], + "evaluations": [ + { + "id": "test-1", + "name": "Addition Test", + "inputs": { + "a": 1, + "b": 4, + "operator": "+" + }, + "evaluationCriterias": { + "ContainsEvaluator": { "searchText": "5" }, + "ExactMatchEvaluator": { + "expectedOutput": { "result": "5.0" } + }, + "LLMJudgeOutputEvaluator": { + "expectedOutput": { "result": 5.0 } + }, + "TrajectoryEvaluator": { + "expectedAgentBehavior": "The agent should correctly add 1 + 4." + } + }, + "updatedAt": "2025-10-29T18:22:31.492Z" + } + ], + "fileName": "default.json", + "updatedAt": "2025-10-29T18:22:31.492Z" +} +``` + +### Evaluation Fields + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Unique evaluation ID | +| `name` | string | Human-readable test name | +| `inputs` | object | Input values matching agent inputSchema | +| `expectedOutput` | object | Expected output (low-code format) | +| `evaluationCriterias` | object | Per-evaluator criteria (coded format) | +| `simulationInstructions` | string | Instructions for input simulation | +| `expectedAgentBehavior` | string | Description of expected agent trajectory | +| `simulateInput` | boolean | Whether to generate synthetic inputs | +| `simulateTools` | boolean | Whether to simulate tool responses | +| `toolsToSimulate` | array | Specific tools to simulate | + +## Built-in Evaluator Types + +### 1. Exact Match (`uipath-exact-match`) + +Checks if output exactly matches expected value. + +```json +{ + "version": "1.0", + "id": "ExactMatchEvaluator", + "description": "Checks if the response text exactly matches the expected value.", + "evaluatorTypeId": "uipath-exact-match", + "evaluatorConfig": { + "name": "ExactMatchEvaluator", + "targetOutputKey": "result", + "negated": false, + "ignoreCase": false, + "defaultEvaluationCriteria": { + "expectedOutput": { "result": "5.0" } + } + } +} +``` + +### 2. Contains (`uipath-contains`) + +Checks if output contains a substring. + +```json +{ + "version": "1.0", + "id": "ContainsEvaluator", + "description": "Checks if the response text includes the expected text.", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "ContainsEvaluator", + "targetOutputKey": "result", + "negated": false, + "ignoreCase": false, + "defaultEvaluationCriteria": { + "searchText": "expected substring" + } + } +} +``` + +### 3. JSON Similarity (`uipath-json-similarity`) + +Compares JSON structures for similarity. + +```json +{ + "version": "1.0", + "id": "JsonSimilarityEvaluator", + "description": "Compares JSON output for structural similarity.", + "evaluatorTypeId": "uipath-json-similarity", + "evaluatorConfig": { + "name": "JsonSimilarityEvaluator", + "targetOutputKey": "*", + "defaultEvaluationCriteria": { + "expectedOutput": { "result": 5 } + } + } +} +``` + +### 4. LLM Judge — Semantic Similarity (`uipath-llm-judge-output-semantic-similarity`) + +Uses an LLM to judge semantic equivalence. + +```json +{ + "version": "1.0", + "id": "LLMJudgeOutputEvaluator", + "description": "Uses an LLM to judge semantic similarity between expected and actual output.", + "evaluatorTypeId": "uipath-llm-judge-output-semantic-similarity", + "evaluatorConfig": { + "name": "LLMJudgeOutputEvaluator", + "targetOutputKey": "*", + "model": "gpt-4.1-2025-04-14", + "prompt": "Compare the following outputs and evaluate their semantic similarity.\n\nActual Output: {{ActualOutput}}\nExpected Output: {{ExpectedOutput}}\n\nProvide a score from 0-100.", + "temperature": 0.0, + "defaultEvaluationCriteria": { + "expectedOutput": { "result": 5.0 } + } + } +} +``` + +### 5. LLM Judge — Strict JSON Similarity (`uipath-llm-judge-strict-json-similarity`) + +Strict JSON comparison via LLM. Same structure as semantic similarity but with +stricter comparison prompt. + +### 6. Trajectory (`uipath-llm-judge-trajectory-similarity`) + +Evaluates the agent's execution path and decision sequence. + +```json +{ + "version": "1.0", + "id": "TrajectoryEvaluator", + "description": "Evaluates the agent's execution trajectory and decision sequence.", + "evaluatorTypeId": "uipath-llm-judge-trajectory-similarity", + "evaluatorConfig": { + "name": "TrajectoryEvaluator", + "model": "gpt-4.1-2025-04-14", + "prompt": "Evaluate the agent's execution trajectory based on the expected behavior.\n\nExpected Agent Behavior: {{ExpectedAgentBehavior}}\nAgent Run History: {{AgentRunHistory}}\n\nProvide a score from 0-100.", + "temperature": 0.0, + "defaultEvaluationCriteria": { + "expectedAgentBehavior": "The agent should correctly perform the task." + } + } +} +``` + +### 7. Tool Call Arguments (`uipath-tool-call-arguments`) + +Validates that the agent called tools with correct arguments. + +```json +{ + "version": "1.0", + "id": "ToolCallArgumentsEvaluator", + "description": "Validates tool call arguments match expected values.", + "evaluatorTypeId": "uipath-tool-call-arguments", + "evaluatorConfig": { + "name": "ToolCallArgumentsEvaluator", + "defaultEvaluationCriteria": { + "expectedToolCalls": [ + { + "toolName": "Web Search", + "arguments": { "query": "expected search query" } + } + ] + } + } +} +``` + +## Custom Python Evaluators (Coded Agents) + +### Evaluator Config + +`coded-evals/evaluators/my-evaluator.json`: + +```json +{ + "version": "1.0", + "id": "MyCustomEvaluator", + "evaluatorTypeId": "file://types/my-evaluator-types.json", + "evaluatorSchema": "file://my_evaluator.py:MyCustomEvaluator", + "description": "Custom evaluator description", + "evaluatorConfig": { + "name": "MyCustomEvaluator", + "defaultEvaluationCriteria": { + "customField": "default-value" + }, + "negated": false + } +} +``` + +### Types File + +`coded-evals/evaluators/custom/types/my-evaluator-types.json`: + +JSON schema for the custom evaluation criteria fields. + +### Python Implementation + +`coded-evals/evaluators/custom/my_evaluator.py`: + +```python +import json +from uipath.eval.evaluators import BaseEvaluator, BaseEvaluationCriteria, BaseEvaluatorConfig +from uipath.eval.models import AgentExecution, EvaluationResult, NumericEvaluationResult +from opentelemetry.sdk.trace import ReadableSpan + +class MyEvaluationCriteria(BaseEvaluationCriteria): + """Evaluation criteria for the custom evaluator.""" + customField: str + +class MyEvaluatorConfig(BaseEvaluatorConfig[MyEvaluationCriteria]): + """Configuration for the custom evaluator.""" + name: str = "MyCustomEvaluator" + negated: bool = False + default_evaluation_criteria: MyEvaluationCriteria = MyEvaluationCriteria( + customField="default" + ) + +class MyCustomEvaluator( + BaseEvaluator[MyEvaluationCriteria, MyEvaluatorConfig, type(None)] +): + """Custom evaluator implementation.""" + + @classmethod + def get_evaluator_id(cls) -> str: + return "MyCustomEvaluator" + + async def evaluate( + self, + agent_execution: AgentExecution, + evaluation_criteria: MyEvaluationCriteria + ) -> EvaluationResult: + # Access agent output + output = agent_execution.output + + # Access agent trace spans + for span in agent_execution.agent_trace: + if span.name == "target_operation": + input_value = json.loads( + span.attributes.get("input.value", "{}") + ) + # Evaluate... + + # Return score 0.0-1.0 + score = 1.0 if condition else 0.0 + if self.evaluator_config.negated: + score = 1.0 - score + + return NumericEvaluationResult(score=score) +``` + +### Key Classes + +| Class | Description | +|-------|-------------| +| `BaseEvaluator` | Base class for all custom evaluators | +| `BaseEvaluationCriteria` | Base for criteria models (pydantic) | +| `BaseEvaluatorConfig` | Base for evaluator configuration | +| `AgentExecution` | Contains `output`, `agent_trace` (spans) | +| `NumericEvaluationResult` | Result with `score` (0.0-1.0) | +| `ReadableSpan` | OpenTelemetry trace span | + +### Mockable Functions for Eval Simulation + +```python +from uipath.eval.mocks import ExampleCall, mockable + +EXAMPLES = [ + ExampleCall(id="example1", input='{"query":"test"}', output='{"result":"mock"}') +] + +@traced() +@mockable(example_calls=EXAMPLES) +async def my_tool(query: str) -> dict: + # Real implementation + ... +``` + +The `@mockable` decorator allows evals to simulate tool responses. + +## Evaluator Quick Reference + +| ID | Type | Score | Criteria Key | +|----|------|-------|-------------| +| `uipath-exact-match` | Deterministic | 0/1 | `expectedOutput` | +| `uipath-contains` | Deterministic | 0/1 | `searchText` | +| `uipath-json-similarity` | Deterministic | 0-1 | `expectedOutput` | +| `uipath-llm-judge-output-semantic-similarity` | LLM | 0-100 | `expectedOutput` | +| `uipath-llm-judge-strict-json-similarity` | LLM | 0-100 | `expectedOutput` | +| `uipath-llm-judge-trajectory-similarity` | LLM | 0-100 | `expectedAgentBehavior` | +| `uipath-tool-call-arguments` | Deterministic | 0/1 | `expectedToolCalls` | +| `file://` custom | Custom Python | 0-1 | Custom fields | diff --git a/skill/uipath-studio/references/solution-structure.md b/skill/uipath-studio/references/solution-structure.md new file mode 100644 index 0000000..d1c74c5 --- /dev/null +++ b/skill/uipath-studio/references/solution-structure.md @@ -0,0 +1,361 @@ +# Solution Structure Reference + +Complete specification of UiPath Solution packaging and deployment resources. + +## .uis File Format + +A `.uis` file is a standard ZIP archive containing the entire solution. It can +be created by zipping the solution directory and renaming to `.uis`. + +## SolutionStorage.json + +Maps project IDs to their relative paths within the solution: + +```json +{ + "SolutionId": "", + "Projects": [ + { + "ProjectId": "", + "ProjectRelativePath": "Agent/project.uiproj" + }, + { + "ProjectId": "", + "ProjectRelativePath": "RPA Workflow/project.uiproj" + } + ] +} +``` + +## Solution Manifest (.uipx) + +The `.uipx` file is a JSON manifest declaring all projects: + +```json +{ + "DocVersion": "1.0.0", + "StudioMinVersion": "2025.04.0", + "SolutionId": "", + "Projects": [ + { + "Type": "Agent", + "ProjectRelativePath": "Agent/project.uiproj", + "Id": "" + }, + { + "Type": "Process", + "ProjectRelativePath": "RPA Workflow/project.uiproj", + "Id": "" + } + ] +} +``` + +### Supported Project Types + +| Type | Description | Key Files | +|------|-------------|-----------| +| `Agent` | AI agent (low-code or coded) | `agent.json`, `entry-points.json` | +| `Process` | RPA workflow | `Main.xaml`, `project.json` | +| `WebApp` | Web application (for HITL UIs) | `.app/` directory, `Main.xaml` | +| `CaseManagement` | Case management flow | `case.stage.json`, `.bpmn` | +| `BusinessRules` | DMN business rules | `*.dmn` | +| `Connector` | Custom connector | connector definitions | +| `ProcessOrchestration` | BPMN process orchestration | `Process.bpmn` | +| `Api` | API workflow (serverless) | `Workflow.json` | + +## Deployment Resources + +The `resources/solution_folder/` directory contains deployment descriptors. + +### Package Resource + +`resources/solution_folder/package/.json`: + +```json +{ + "docVersion": "1.0.0", + "resource": { + "name": "Agent", + "kind": "package", + "apiVersion": "orchestrator.uipath.com/v1", + "projectKey": "", + "dependencies": [], + "runtimeDependencies": [], + "files": [], + "folders": [{ "fullyQualifiedName": "solution_folder" }], + "spec": { + "fileName": null, + "fileReference": null, + "name": "Agent", + "description": null + }, + "locks": [], + "key": "" + } +} +``` + +### Process Resource + +`resources/solution_folder/process/agent/.json`: + +```json +{ + "docVersion": "1.0.0", + "resource": { + "name": "Agent", + "kind": "process", + "type": "agent", + "apiVersion": "orchestrator.uipath.com/v1", + "projectKey": "", + "dependencies": [ + { "name": "Agent", "kind": "package" } + ], + "runtimeDependencies": [], + "files": [], + "folders": [{ "fullyQualifiedName": "solution_folder" }], + "spec": { + "entryPointUniqueId": null, + "type": "Agent", + "name": "Agent", + "description": null, + "package": { "key": "" }, + "packageName": ".agent.Agent", + "inputArguments": "{}", + "hiddenForAttendedUser": false, + "alwaysRunning": false, + "autoStartProcess": false, + "targetFrameworkValue": "Portable", + "agentMemory": false, + "retentionAction": "Delete", + "retentionPeriod": 30, + "staleRetentionAction": "Delete", + "staleRetentionPeriod": 180, + "tags": [] + }, + "locks": [], + "key": "" + } +} +``` + +### Process Types by Project Type + +| Project Type | Process `type` | Process `kind` | +|-------------|----------------|----------------| +| Agent | `agent` | `process` | +| Process/RPA | — | `process` | +| WebApp | — | `process` (under `webApp/`) | +| CaseManagement | — | `process` (under `caseManagement/`) | +| ProcessOrchestration | — | `process` (under `processOrchestration/`) | +| Api | — | `process` (under `api/`) | + +### Connection Resource + +`resources/solution_folder/connection//.json`: + +```json +{ + "docVersion": "1.0.0", + "resource": { + "name": "UiPath GenAI Activities", + "kind": "connection", + "apiVersion": "elements.uipath.com/v1", + "dependencies": [], + "runtimeDependencies": [], + "files": [], + "folders": [{ "fullyQualifiedName": "solution_folder" }], + "spec": { + "connectorKey": "uipath-uipath-airdk", + "connectorVersion": null, + "name": "UiPath GenAI Activities" + }, + "locks": [], + "key": "" + } +} +``` + +### Index Resource + +`resources/solution_folder/index/.json`: + +```json +{ + "docVersion": "1.0.0", + "resource": { + "name": "MyIndex", + "kind": "index", + "apiVersion": "ecs.uipath.com/v1", + "dependencies": [], + "runtimeDependencies": [], + "files": [], + "folders": [{ "fullyQualifiedName": "solution_folder" }], + "spec": { + "name": "MyIndex", + "description": "Semantic search index", + "indexConfigurationJson": "{...}" + }, + "locks": [], + "key": "" + } +} +``` + +The `indexConfigurationJson` is a JSON string containing: + +```json +{ + "Version": 2, + "Provider": 3, + "DataSource": { + "Type": 1, + "Properties": { + "folderName": "Shared", + "directoryPath": "/", + "storageBucketName": "MyIndex", + "storageBucketId": "00000000-0000-0000-0000-000000000000", + "fileNameGlob": "*" + } + }, + "EmbeddingModel": "text-embedding-3-large", + "ExtractionStrategy": null, + "UserFields": [] +} +``` + +### App Version Resource + +`resources/solution_folder/appVersion/.json`: + +```json +{ + "docVersion": "1.0.0", + "resource": { + "name": "MyApp", + "kind": "appVersion", + "apiVersion": "apps.uipath.com/v2", + "projectKey": "", + "dependencies": [], + "runtimeDependencies": [], + "files": [], + "folders": [{ "fullyQualifiedName": "solution_folder" }], + "spec": { + "name": "MyApp", + "description": null, + "isAppPublic": false + }, + "locks": [], + "key": "" + } +} +``` + +## Multi-Project Solution Example (EverythingBagel) + +A solution with all project types: + +```json +{ + "DocVersion": "1.0.0", + "StudioMinVersion": "2025.04.0", + "SolutionId": "", + "Projects": [ + { "Type": "BusinessRules", "ProjectRelativePath": "Business Rules/project.uiproj", "Id": "" }, + { "Type": "WebApp", "ProjectRelativePath": "SimpleApprovalApp/project.uiproj", "Id": "" }, + { "Type": "CaseManagement", "ProjectRelativePath": "Agentic case/project.uiproj", "Id": "" }, + { "Type": "Process", "ProjectRelativePath": "RPA Workflow/project.uiproj", "Id": "" }, + { "Type": "Connector", "ProjectRelativePath": "Connector/project.uiproj", "Id": "" }, + { "Type": "ProcessOrchestration", "ProjectRelativePath": "Agentic Process/project.uiproj", "Id": "" }, + { "Type": "Agent", "ProjectRelativePath": "Agent/project.uiproj", "Id": "" }, + { "Type": "Api", "ProjectRelativePath": "API Workflow/project.uiproj", "Id": "" } + ] +} +``` + +## Project Type Key Files + +### ProcessOrchestration — Process.bpmn + +BPMN 2.0 XML with UiPath extensions: + +```xml + + + + + + + + + + +``` + +### CaseManagement — case.stage.json + +```json +{ + "root": { + "id": "root", + "type": "case-management:root", + "name": "My Case", + "caseIdentifierType": "constant", + "caseIdentifier": "CASE", + "caseAppEnabled": true + }, + "nodes": [ + { + "id": "trigger_1", + "type": "case-management:Trigger", + "position": { "x": 160, "y": 198.5 }, + "data": { "parentElement": { "id": "root", "type": "case-management:root" } } + }, + { + "id": "stage_1", + "type": "case-management:Stage", + "position": { "x": 326, "y": 200 }, + "data": { + "label": "Stage 1", + "parentElement": { "id": "root", "type": "case-management:root" }, + "tasks": [] + } + } + ], + "edges": [ + { + "id": "edge_initial", + "source": "trigger_1", + "target": "stage_1", + "type": "case-management:TriggerEdge" + } + ] +} +``` + +### Api — Workflow.json + +ServerlessV2 DSL: + +```json +{ + "document": { + "dsl": "1.0.0", + "name": "Workflow", + "version": "0.0.1", + "namespace": "default", + "metadata": { "variables": [] } + }, + "do": [ + { + "Sequence_1": { + "do": [], + "metadata": { "fullName": "Sequence", "activityType": "Sequence" } + } + } + ], + "evaluate": { "mode": "strict", "language": "javascript" } +} +``` diff --git a/skill/uipath-studio/references/studio-web-api.md b/skill/uipath-studio/references/studio-web-api.md new file mode 100644 index 0000000..7f6ee51 --- /dev/null +++ b/skill/uipath-studio/references/studio-web-api.md @@ -0,0 +1,391 @@ +# Studio Web API Reference + +Studio Web API endpoints for managing solutions, projects, deployments, and +resources. Base URL: `https://cloud.uipath.com/{orgId}/studio_/backend` + +Swagger UI: `https://alpha.uipath.com/studioweb/studio_/backend/swagger/index.html` + +## Authentication + +All endpoints require Bearer token authentication. Obtain tokens via: +- UiPath CLI OAuth flow (`uipath config --auth login`) +- Client credentials (`uipath identity token create`) +- Personal Access Token (PAT) + +## Solution Management + +### Create Solution +``` +POST /api/external/Solution +POST /api/Solution +``` + +### Get Solution +``` +GET /api/external/Solution/{solutionId} +GET /api/Solution/{solutionId} +``` + +### Update Solution +``` +POST /api/external/Solution/Update/{solutionId} +POST /api/Solution/Update/{solutionId} +``` + +### Delete Solution +``` +DELETE /api/external/Solution/{solutionId} +DELETE /api/Solution/{solutionId} +``` + +### Search Solutions +``` +GET /api/Solution/SearchSolutionsAndProjects +GET /api/Solution/OrganizationSolutionsAndProjects +GET /api/Solution/Name/{solutionName}/Ids +``` + +### Import Solution from ZIP +``` +POST /api/Solution/Import +POST /api/Solution/AddSolution +POST /api/Solution/ImportAgentAsSolution +``` + +### Export Solution to ZIP +``` +GET /api/Solution/{solutionId}/Export +GET /api/Solution/Studio/{solutionId}/Export +``` + +### Overwrite Solution from ZIP +``` +POST /api/Solution/{solutionId}/Overwrite +``` + +## Project Management within Solution + +### Add Project +``` +POST /api/external/Solution/{solutionId}/Projects +POST /api/Solution/{solutionId}/Projects +``` + +### Delete Project +``` +DELETE /api/external/Solution/{solutionId}/{projectId} +DELETE /api/Solution/{solutionId}/{projectId} +``` + +### Duplicate Solution +``` +POST /api/Solution/{solutionId}/duplicate +``` + +## Publishing (Traditional) + +### Create Publish Request +``` +POST /api/external/Solution/{solutionId}/Publish-Requests +POST /api/Solution/{solutionId}/Publish-Requests +``` + +### Get Publish Request Status +``` +GET /api/Solution/{solutionId}/Publish-Requests/{publishRequestId} +``` + +### Publish Specific Project +``` +POST /api/Solution/{solutionId}/Project-Publish-Requests +GET /api/Solution/{solutionId}/Project-Publish-Requests/{publishRequestId} +``` + +### Get Publish Status +``` +GET /api/external/Solution/{solutionId}/Publish-Status +GET /api/Solution/{solutionId}/Publish-Status +``` + +### Get Published Versions +``` +GET /api/Solution/{solutionId}/Published-Versions +GET /api/Solution/{solutionId}/Next-Publish-Version +``` + +## ResourceBuilder (Maestro Deploy/Debug) + +The ResourceBuilder API handles Maestro-style deployments with resource +management, overwrites, and test configuration. + +### Deploy Solution +``` +POST /api/resourcebuilder/solutions/{solutionKey}/deploy +``` + +Request body: `SolutionDeploymentRequest` with `packageVersionKey`, +`installationFolderKey`, `authenticationInfo`. + +### Debug Solution +``` +POST /api/resourcebuilder/solutions/{solutionKey}/debug +``` + +### Debug Individual Project +``` +POST /api/resourcebuilder/solutions/{solutionKey}/projects/{projectKey}/debug +``` + +### Apply Test Configuration +``` +POST /api/resourcebuilder/solutions/{solutionKey}/applyTestConfiguration +``` + +### Get Deployment Info +``` +GET /api/resourcebuilder/solutions/{solutionKey}/deployment/entities +GET /api/resourcebuilder/solutions/{solutionKey}/deployment/resources +GET /api/resourcebuilder/solutions/{solutionKey}/deployment/resource-stats +``` + +### Debug Provisioning Status +``` +GET /api/Solution/{solutionId}/Debug-Provisioning-Status +PATCH /api/Solution/{solutionId}/Publish-Requests/{publishRequestId} +``` + +### Resource Management +``` +GET /api/resourcebuilder/solutions/{solutionKey}/resources/search +GET /api/resourcebuilder/solutions/{solutionKey}/resources/{resourceKey} +DELETE /api/resourcebuilder/solutions/{solutionKey}/resources/{resourceKey} +GET /api/resourcebuilder/solutions/{solutionKey}/resources/{resourceKey}/configuration +PATCH /api/resourcebuilder/solutions/{solutionKey}/resources/{resourceKey}/configuration +POST /api/resourcebuilder/solutions/{solutionKey}/resources/{resourceKey}/sync-configuration +POST /api/resourcebuilder/solutions/{solutionKey}/resources/reference +POST /api/resourcebuilder/solutions/{solutionKey}/resources/virtual +``` + +### Resource Overwrites +``` +GET /api/resourcebuilder/solutions/{solutionKey}/overwrites +PATCH /api/resourcebuilder/solutions/{solutionKey}/overwrites +POST /api/resourcebuilder/solutions/{solutionKey}/overwrite +``` + +### Validate +``` +POST /api/resourcebuilder/solutions/{solutionKey}/validate/definition +GET /api/resourcebuilder/solutions/{solutionKey}/validate +POST /api/resourcebuilder/{solutionKey}/validate-bindings +``` + +### Publish Locations +``` +GET /api/resourcebuilder/solutions/publish-location +``` + +## Snapshots (Version Control) + +### Create Snapshot +``` +POST /api/Solution/{solutionId}/Snapshot +``` + +### List Snapshots +``` +GET /api/Solution/{solutionId}/Snapshots +``` + +### Export Snapshot +``` +GET /api/Solution/{solutionId}/Snapshot/{snapshotId}/Export +``` + +### Open Snapshot (Readonly) +``` +GET /api/Solution/{solutionId}/Snapshot/{snapshotId}/Open +``` + +### Restore from Snapshot +``` +POST /api/Solution/{solutionId}/Restore/{snapshotId} +``` + +### Get File from Snapshot +``` +GET /api/Solution/{solutionId}/Snapshot/{snapshotId}/FileOperations/Structure +GET /api/Solution/{solutionId}/Snapshot/{snapshotId}/FileOperations/File/{fileId} +``` + +## File Operations + +### Get Project File Structure +``` +GET /api/Project/{projectId}/FileOperations/Structure +``` + +### Create File +``` +POST /api/Project/{projectId}/FileOperations/File +``` + +### Get File Contents +``` +GET /api/Project/{projectId}/FileOperations/File/{fileId} +``` + +### Update File +``` +PUT /api/Project/{projectId}/FileOperations/File/{fileId} +``` + +### Rename File +``` +POST /api/Project/{projectId}/FileOperations/File/Rename +``` + +### Get/Set Entry Points +``` +GET /api/Project/{projectId}/FileOperations/EntryPoints +POST /api/Project/{projectId}/FileOperations/EntryPoints +``` + +### Set Main File +``` +PUT /api/Project/{projectId}/FileOperations/SetMain/{fileId} +``` + +### Create Folder +``` +POST /api/Project/{projectId}/FileOperations/Folder +``` + +### Move Folder +``` +POST /api/Project/{projectId}/FileOperations/Folder/Move +``` + +### Delete File or Folder +``` +DELETE /api/Project/{projectId}/FileOperations/Delete/{itemId} +``` + +## External Project API + +### Export Project Version +``` +GET /api/ExternalProject/export-version/{originalProjectId}/{version} +``` +Exports in `.uip` format. + +### List Published Versions +``` +GET /api/ExternalProject/versions/{projectId} +``` + +### Import Project Version +``` +POST /api/ExternalProject/import-version +``` +Expects `.uip` archive. + +### Create from Snapshot +``` +POST /api/ExternalProject/create-from-snapshot +``` + +## Build & Package + +### Create Build +``` +POST /api/Build/{projectId} +``` + +### Get Build Version +``` +GET /api/Build/{projectId}/Version/{buildVersion} +``` + +### Build Payload +``` +POST /api/Build/{projectId}/BuildPayload +POST /api/Build/BuildPayload +GET /api/Build/BuildPayload/{buildPayloadId} +``` + +## Sharing + +### Share Solution +``` +POST /api/ShareSolution +DELETE /api/ShareSolution +GET /api/ShareSolution/SharedEntities +``` + +### Share Project +``` +POST /api/ShareProject +DELETE /api/ShareProject +GET /api/ShareProject/Users +``` + +## Solution Locking + +### Acquire/Release Lock +``` +POST /api/Solution/{solutionId}/Lock/{lockKey} +DELETE /api/Solution/{solutionId}/Lock/{lockKey} +GET /api/Solution/{solutionId}/LockInfo/{lockKey} +GET /api/Solution/{solutionId}/AllLocks +``` + +### Per-Resource Lock +``` +PUT /api/Solution/{solutionId}/PerResourceLock/{lockKey} +DELETE /api/Solution/{solutionId}/PerResourceLock/{lockKey} +POST /api/Solution/{solutionId}/PerResourceLock +``` + +## Sessions + +### Allocate Robot Session +``` +POST /api/Session +``` + +### Allocate Designer Session +``` +POST /api/Session/Designer +``` + +## Templates + +### Search Templates +``` +POST /api/Template/SearchTemplates +GET /api/Template/GetSystemTemplates +``` + +### Create from Template +``` +POST /api/Template/CreateProjectFromTemplate +``` + +### Create Template from Project +``` +POST /api/Template/CreateTemplateFromProject +``` + +## Local Solution API + +For interpreting and generating local solution files without server storage: + +``` +POST /api/LocalSolution # Interpret local solution files +PUT /api/LocalSolution # Generate new local solution files +POST /api/LocalSolution/Project # Add project to local solution +DELETE /api/LocalSolution/Project # Remove project from local solution +PUT /api/LocalSolution/Project # Create local project files +PUT /api/LocalSolution/Workflow # Generate workflow file content +``` diff --git a/skill/uipath-studio/references/tool-types.md b/skill/uipath-studio/references/tool-types.md new file mode 100644 index 0000000..ebb3f07 --- /dev/null +++ b/skill/uipath-studio/references/tool-types.md @@ -0,0 +1,393 @@ +# Tool Types Reference + +Complete schemas for all agent tool/resource types in UiPath. + +Tools are added to an agent by creating a `resource.json` file in +`Agent/resources//resource.json`. + +For low-code agents, tools are also registered inline in the +`.agent-builder/agent.json` under the `resources` array. + +## 1. Integration Tool (External) + +External tools that call APIs through UiPath connectors. + +### Web Search Example + +```json +{ + "$resourceType": "tool", + "name": "Web Search", + "description": "Web search executes a search of the public domain using a natural language search query.", + "location": "external", + "type": "integration", + "inputSchema": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "title": "Search Engine", + "enum": ["GoogleCustomSearch"], + "oneOf": [{ "const": "GoogleCustomSearch", "title": "GoogleCustomSearch" }] + }, + "query": { + "type": "string", + "title": "Search", + "description": "The natural language query to search the web for" + }, + "num": { + "type": "integer", + "title": "Number of results", + "description": "The number of results. Default to 10." + } + }, + "required": ["provider", "query"] + }, + "outputSchema": { + "type": "object", + "properties": { + "results[*]": { + "type": "array", + "title": "Results", + "items": { "$ref": "#/definitions/results[*]" } + }, + "formattedResults": { + "type": "string", + "title": "Formatted results" + } + }, + "definitions": { + "results[*]": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "snippet": { "type": "string" }, + "url": { "type": "string" } + } + } + } + }, + "settings": {}, + "properties": { + "toolPath": "/v2/webSearch", + "objectName": "v2::webSearch", + "toolDisplayName": "Web Search", + "method": "POST", + "connection": { + "id": "", + "name": "UiPath GenAI Activities", + "state": "enabled", + "connector": { + "key": "uipath-uipath-airdk", + "name": "UiPath GenAI Activities", + "enabled": true + }, + "folder": { + "key": "", + "path": "" + }, + "solutionProperties": { + "resourceKey": "" + } + }, + "parameters": [ + { + "name": "provider", + "displayName": "Search Engine", + "type": "string", + "fieldLocation": "body", + "fieldVariant": "static", + "value": "GoogleCustomSearch", + "dynamic": false, + "position": "primary", + "sortOrder": 1, + "required": true + }, + { + "name": "query", + "displayName": "Search", + "type": "string", + "fieldLocation": "body", + "fieldVariant": "dynamic", + "value": "{{prompt}}", + "dynamic": true, + "position": "primary", + "sortOrder": 2, + "required": true + } + ], + "bodyStructure": { "contentType": "json" } + }, + "guardrail": { "policies": [] }, + "id": "", + "isPreview": false, + "isEnabled": true +} +``` + +### Common Integration Tools + +| Tool | toolPath | Connector | +|------|----------|-----------| +| Web Search | `/v2/webSearch` | `uipath-uipath-airdk` | +| Web Reader | `/v1/webRead` | `uipath-uipath-airdk` | +| Web Summary | `/v1/webSummary` | `uipath-uipath-airdk` | + +### Parameter Field Variants + +| Variant | Description | +|---------|-------------| +| `static` | Fixed value, not changeable by LLM | +| `dynamic` | LLM provides the value, `{{prompt}}` in template | + +## 2. Agent Tool (Solution) + +References another agent within the same solution. + +```json +{ + "$resourceType": "tool", + "id": "", + "referenceKey": "", + "name": "Agent 2", + "type": "agent", + "description": "Used to add things", + "location": "solution", + "isEnabled": true, + "inputSchema": { + "type": "object", + "properties": { + "number1": { "type": "number", "description": "The first number" }, + "number2": { "type": "number", "description": "The second number" } + }, + "required": ["number1", "number2"] + }, + "outputSchema": { + "type": "object", + "properties": { + "sum": { "type": "number", "description": "The sum" } + } + }, + "settings": {}, + "guardrail": { "policies": [] }, + "argumentProperties": {}, + "properties": { + "processName": "Agent 2", + "folderPath": "solution_folder" + } +} +``` + +The `referenceKey` links to the target agent's process resource key in +`resources/solution_folder/process/agent/.json`. + +## 3. Internal Tool (Built-in) + +Built-in UiPath tools that do not require external connections. + +### Analyze Files Tool + +```json +{ + "$resourceType": "tool", + "referenceKey": null, + "name": "Analyze Files", + "type": "internal", + "description": "Analyze one or more files with an LLM to extract, synthesize, or answer queries about their content.", + "isEnabled": true, + "inputSchema": { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "items": { "$ref": "#/definitions/job-attachment" }, + "description": "Array of files to process" + }, + "analysisTask": { + "type": "string", + "description": "The task or question for processing the files" + } + }, + "required": ["attachments", "analysisTask"], + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": { "type": "string", "description": "Orchestrator attachment key" }, + "FullName": { "type": "string", "description": "File name" }, + "MimeType": { "type": "string", "description": "MIME type" }, + "Metadata": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "required": ["ID"], + "x-uipath-resource-kind": "JobAttachment" + } + } + }, + "outputSchema": { + "type": "object", + "properties": { + "analysis": { + "type": "string", + "description": "Analysis result" + } + }, + "required": ["analysis"] + }, + "settings": {}, + "guardrail": { "policies": [] }, + "argumentProperties": {}, + "properties": { + "toolType": "analyze-attachments" + }, + "id": "" +} +``` + +## 4. Context Resource (RAG/Index) + +Provides semantic search over indexed documents. + +```json +{ + "$resourceType": "context", + "name": "MyKnowledgeBase", + "description": "Semantic search over support tickets", + "folderPath": "Solution Folder", + "indexName": "MyKnowledgeBase", + "id": "", + "referenceKey": null, + "settings": { + "query": { + "description": "The query for the Semantic strategy.", + "variant": "dynamic" + }, + "folderPathPrefix": { "variant": "static" }, + "threshold": 0.5, + "resultCount": 3, + "retrievalMode": "semantic", + "fileExtension": { "value": "All" } + } +} +``` + +### Context Settings + +| Field | Type | Description | +|-------|------|-------------| +| `threshold` | number | Similarity threshold (0-1) | +| `resultCount` | integer | Number of results to return | +| `retrievalMode` | string | `"semantic"`, `"keyword"`, or `"hybrid"` | +| `fileExtension` | object | File type filter | + +## 5. Escalation (HITL — Human-in-the-Loop) + +Routes to Action Center for human approval/review. + +```json +{ + "$resourceType": "escalation", + "id": "", + "name": "AskConfirmation", + "description": "", + "channels": [ + { + "id": "", + "name": "Channel", + "description": "Channel description", + "inputSchema": { + "type": "object", + "properties": { + "Content": { "type": "string" }, + "Comment": { "type": "string", "description": "User comments" } + } + }, + "outputSchema": { + "type": "object", + "properties": { + "Comment": { "type": "string", "description": "User comments" } + } + }, + "outcomeMapping": { + "approve": "continue", + "reject": "continue" + }, + "recipients": [ + { + "type": 1, + "value": "", + "displayName": "Reviewer Name" + } + ], + "type": "actionCenter", + "properties": { + "appName": "SimpleApprovalApp", + "appVersion": 1, + "resourceKey": "", + "isActionableMessageEnabled": true, + "actionableMessageMetaData": { + "fieldSet": { + "type": "fieldSet", + "id": "", + "fields": [ + { "id": "Content", "name": "Content", "type": "Fact" }, + { "id": "Comment", "name": "Comment", "type": "Input.Text" } + ] + }, + "actionSet": { + "type": "actionSet", + "id": "", + "actions": [ + { "id": "approve", "name": "approve", "title": "approve", "type": "Action.Http", "isPrimary": true }, + { "id": "reject", "name": "reject", "title": "reject", "type": "Action.Http", "isPrimary": true } + ] + } + } + } + } + ], + "isAgentMemoryEnabled": false, + "governanceProperties": { "isEscalatedAtRuntime": false }, + "escalationType": 0, + "properties": {} +} +``` + +### Outcome Mapping Options + +| Outcome | Maps To | Description | +|---------|---------|-------------| +| `approve` | `continue` | Resume agent execution | +| `reject` | `continue` | Resume (agent handles rejection) | +| `approve` | `stop` | Stop agent on approval | +| `reject` | `stop` | Stop agent on rejection | + +### Recipient Types + +| Type | Description | +|------|-------------| +| `1` | Specific user (by UUID) | +| `2` | Group | +| `3` | Dynamic (resolved at runtime) | + +## Guardrails + +All tool types support guardrail policies: + +```json +{ + "guardrail": { + "policies": [ + { + "name": "content-filter", + "enabled": true, + "config": { ... } + } + ] + } +} +``` + +Currently, an empty `policies` array is the default. diff --git a/skill/uipath-studio/scripts/solution_create.sh b/skill/uipath-studio/scripts/solution_create.sh new file mode 100755 index 0000000..bd62134 --- /dev/null +++ b/skill/uipath-studio/scripts/solution_create.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# Creates a new UiPath Solution scaffold with the specified project type. +# +# Usage: solution_create.sh [project_name] +# +# Project types: Agent, Process, WebApp, CaseManagement, BusinessRules, +# Connector, ProcessOrchestration, Api +# +# Example: solution_create.sh "MyAgent" "Agent" +# solution_create.sh "MyWorkflow" "Agent" "ResearchBot" + +set -euo pipefail + +SOLUTION_NAME="${1:?Usage: solution_create.sh [project_name]}" +PROJECT_TYPE="${2:?Usage: solution_create.sh [project_name]}" +PROJECT_NAME="${3:-Agent}" + +# Generate UUIDs +gen_uuid() { + python3 -c "import uuid; print(str(uuid.uuid4()))" 2>/dev/null \ + || cat /proc/sys/kernel/random/uuid 2>/dev/null \ + || uuidgen 2>/dev/null \ + || echo "$(od -x /dev/urandom | head -1 | awk '{OFS="-"; print $2$3,$4,$5,$6,$7$8$9}')" +} + +SOLUTION_ID=$(gen_uuid) +PROJECT_ID=$(gen_uuid) +PROJECT_UUID=$(gen_uuid) +PACKAGE_KEY=$(gen_uuid) +PROCESS_KEY=$(gen_uuid) + +# Create directory structure +mkdir -p "${SOLUTION_NAME}/${PROJECT_NAME}" +mkdir -p "${SOLUTION_NAME}/resources/solution_folder/package" +mkdir -p "${SOLUTION_NAME}/resources/solution_folder/process/agent" + +# SolutionStorage.json +cat > "${SOLUTION_NAME}/SolutionStorage.json" << EOF +{"SolutionId":"${SOLUTION_ID}","Projects":[{"ProjectId":"${PROJECT_ID}","ProjectRelativePath":"${PROJECT_NAME}/project.uiproj"}]} +EOF + +# Solution manifest (.uipx) +cat > "${SOLUTION_NAME}/${SOLUTION_NAME}.uipx" << EOF +{ + "DocVersion": "1.0.0", + "StudioMinVersion": "2025.04.0", + "SolutionId": "${SOLUTION_ID}", + "Projects": [ + { + "Type": "${PROJECT_TYPE}", + "ProjectRelativePath": "${PROJECT_NAME}/project.uiproj", + "Id": "${PROJECT_UUID}" + } + ] +} +EOF + +# project.uiproj +cat > "${SOLUTION_NAME}/${PROJECT_NAME}/project.uiproj" << EOF +{ + "ProjectType": "${PROJECT_TYPE}", + "Name": "${PROJECT_NAME}", + "Description": null, + "MainFile": null +} +EOF + +# Package resource +cat > "${SOLUTION_NAME}/resources/solution_folder/package/${PROJECT_NAME}.json" << EOF +{ + "docVersion": "1.0.0", + "resource": { + "name": "${PROJECT_NAME}", + "kind": "package", + "apiVersion": "orchestrator.uipath.com/v1", + "projectKey": "${PROJECT_UUID}", + "dependencies": [], + "runtimeDependencies": [], + "files": [], + "folders": [{"fullyQualifiedName": "solution_folder"}], + "spec": { + "fileName": null, + "fileReference": null, + "name": "${PROJECT_NAME}", + "description": null + }, + "locks": [], + "key": "${PACKAGE_KEY}" + } +} +EOF + +# Process resource +cat > "${SOLUTION_NAME}/resources/solution_folder/process/agent/${PROJECT_NAME}.json" << EOF +{ + "docVersion": "1.0.0", + "resource": { + "name": "${PROJECT_NAME}", + "kind": "process", + "type": "agent", + "apiVersion": "orchestrator.uipath.com/v1", + "projectKey": "${PROJECT_UUID}", + "dependencies": [{"name": "${PROJECT_NAME}", "kind": "package"}], + "runtimeDependencies": [], + "files": [], + "folders": [{"fullyQualifiedName": "solution_folder"}], + "spec": { + "entryPointUniqueId": null, + "type": "Agent", + "name": "${PROJECT_NAME}", + "description": null, + "package": {"key": "${PACKAGE_KEY}"}, + "packageName": "${SOLUTION_NAME}.agent.${PROJECT_NAME}", + "inputArguments": "{}", + "hiddenForAttendedUser": false, + "alwaysRunning": false, + "autoStartProcess": false, + "targetFrameworkValue": "Portable", + "agentMemory": false, + "retentionAction": "Delete", + "retentionPeriod": 30, + "staleRetentionAction": "Delete", + "staleRetentionPeriod": 180, + "tags": [] + }, + "locks": [], + "key": "${PROCESS_KEY}" + } +} +EOF + +# Create Agent-specific files if project type is Agent +if [ "${PROJECT_TYPE}" = "Agent" ]; then + ENTRY_POINT_ID=$(gen_uuid) + + mkdir -p "${SOLUTION_NAME}/${PROJECT_NAME}/.agent-builder" + mkdir -p "${SOLUTION_NAME}/${PROJECT_NAME}/.project" + mkdir -p "${SOLUTION_NAME}/${PROJECT_NAME}/evals/eval-sets" + mkdir -p "${SOLUTION_NAME}/${PROJECT_NAME}/evals/evaluators" + + # agent.json + cat > "${SOLUTION_NAME}/${PROJECT_NAME}/agent.json" << EOF +{ + "version": "1.1.0", + "settings": { + "model": "anthropic.claude-haiku-4-5-20251001-v1:0", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + "maxIterations": 25 + }, + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string"} + }, + "required": ["query"] + }, + "outputSchema": { + "type": "object", + "properties": { + "content": {"type": "string", "description": "Output content"} + } + }, + "metadata": { + "storageVersion": "44.0.0", + "isConversational": false, + "showProjectCreationExperience": true, + "targetRuntime": "pythonAgent" + }, + "type": "lowCode", + "projectId": "${PROJECT_ID}", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant.", + "contentTokens": [{"type": "simpleText", "rawString": "You are a helpful assistant."}] + }, + { + "role": "user", + "content": "query: {{query}}", + "contentTokens": [ + {"type": "simpleText", "rawString": "query: "}, + {"type": "variable", "rawString": "input.query"}, + {"type": "simpleText", "rawString": ""} + ] + } + ] +} +EOF + + # entry-points.json + cat > "${SOLUTION_NAME}/${PROJECT_NAME}/entry-points.json" << EOF +{ + "\$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", + "\$id": "entry-points.json", + "entryPoints": [ + { + "uniqueId": "${ENTRY_POINT_ID}", + "type": "agent", + "input": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"] + }, + "output": { + "type": "object", + "properties": {"content": {"type": "string", "description": "Output content"}} + } + } + ] +} +EOF + + # flow-layout.json + echo '{}' > "${SOLUTION_NAME}/${PROJECT_NAME}/flow-layout.json" + + # .project/JitCustomTypes.json + echo '{}' > "${SOLUTION_NAME}/${PROJECT_NAME}/.project/JitCustomTypes.json" + + # .agent-builder files + cat > "${SOLUTION_NAME}/${PROJECT_NAME}/.agent-builder/bindings.json" << EOF +{"version": "2.0", "resources": []} +EOF + cp "${SOLUTION_NAME}/${PROJECT_NAME}/entry-points.json" \ + "${SOLUTION_NAME}/${PROJECT_NAME}/.agent-builder/entry-points.json" + + # Default evaluators + EVAL_SET_ID=$(gen_uuid) + EVAL_ID=$(gen_uuid) + EVALUATOR_ID=$(gen_uuid) + TRAJECTORY_ID=$(gen_uuid) + + cat > "${SOLUTION_NAME}/${PROJECT_NAME}/evals/eval-sets/evaluation-set-default.json" << EOF +{ + "fileName": "evaluation-set-default.json", + "id": "${EVAL_SET_ID}", + "name": "Default Evaluation Set", + "batchSize": 10, + "evaluatorRefs": ["${EVALUATOR_ID}"], + "evaluations": [] +} +EOF + + cat > "${SOLUTION_NAME}/${PROJECT_NAME}/evals/evaluators/evaluator-default.json" << EOF +{ + "version": "1.0", + "id": "${EVALUATOR_ID}", + "description": "Uses an LLM to judge semantic similarity.", + "evaluatorTypeId": "uipath-llm-judge-output-semantic-similarity", + "evaluatorConfig": { + "name": "SemanticSimilarityEvaluator", + "targetOutputKey": "*", + "model": "gpt-4.1-2025-04-14", + "prompt": "Compare the outputs and evaluate semantic similarity.\n\nActual: {{ActualOutput}}\nExpected: {{ExpectedOutput}}\n\nScore 0-100.", + "temperature": 0.0, + "defaultEvaluationCriteria": {"expectedOutput": {"content": ""}} + } +} +EOF + + cat > "${SOLUTION_NAME}/${PROJECT_NAME}/evals/evaluators/evaluator-default-trajectory.json" << EOF +{ + "version": "1.0", + "id": "${TRAJECTORY_ID}", + "description": "Evaluates agent execution trajectory.", + "evaluatorTypeId": "uipath-llm-judge-trajectory-similarity", + "evaluatorConfig": { + "name": "TrajectoryEvaluator", + "model": "gpt-4.1-2025-04-14", + "prompt": "Evaluate trajectory.\n\nExpected: {{ExpectedAgentBehavior}}\nHistory: {{AgentRunHistory}}\n\nScore 0-100.", + "temperature": 0.0, + "defaultEvaluationCriteria": {"expectedAgentBehavior": "The agent should correctly perform the task."} + } +} +EOF +fi + +echo "Solution '${SOLUTION_NAME}' created successfully with ${PROJECT_TYPE} project '${PROJECT_NAME}'" +echo "Directory: ${SOLUTION_NAME}/" +find "${SOLUTION_NAME}" -type f | sort | sed 's/^/ /' diff --git a/skill/uipath-studio/scripts/solution_pack.sh b/skill/uipath-studio/scripts/solution_pack.sh new file mode 100755 index 0000000..9f277d6 --- /dev/null +++ b/skill/uipath-studio/scripts/solution_pack.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Packs a UiPath solution directory into a .uis file (ZIP archive). +# +# Usage: solution_pack.sh [output_file] +# +# If output_file is not specified, creates .uis in the +# current directory. +# +# Example: solution_pack.sh ./MySolution +# solution_pack.sh ./MySolution ~/Desktop/MySolution.uis + +set -euo pipefail + +SOLUTION_DIR="${1:?Usage: solution_pack.sh [output_file]}" +SOLUTION_DIR="${SOLUTION_DIR%/}" # Remove trailing slash + +# Resolve to absolute path +SOLUTION_DIR="$(cd "${SOLUTION_DIR}" && pwd)" + +# Determine output filename (absolute path) +if [ -n "${2:-}" ]; then + OUTPUT_FILE="$(cd "$(dirname "$2")" 2>/dev/null && pwd)/$(basename "$2")" 2>/dev/null || OUTPUT_FILE="$(pwd)/$2" +else + BASENAME=$(basename "${SOLUTION_DIR}") + OUTPUT_FILE="$(pwd)/${BASENAME}.uis" +fi + +# Validate solution structure +if [ ! -f "${SOLUTION_DIR}/SolutionStorage.json" ]; then + echo "ERROR: ${SOLUTION_DIR}/SolutionStorage.json not found." >&2 + echo "This does not appear to be a valid UiPath solution directory." >&2 + exit 1 +fi + +# Find .uipx manifest +UIPX_FILE=$(find "${SOLUTION_DIR}" -maxdepth 1 -name "*.uipx" -type f | head -1) +if [ -z "${UIPX_FILE}" ]; then + echo "ERROR: No .uipx manifest found in ${SOLUTION_DIR}/" >&2 + exit 1 +fi + +# Remove existing output file if present +if [ -f "${OUTPUT_FILE}" ]; then + rm "${OUTPUT_FILE}" +fi + +# Create ZIP archive from inside the solution directory +cd "${SOLUTION_DIR}" +zip -r "${OUTPUT_FILE}" . -x ".git/*" -x "__pycache__/*" -x "*.pyc" +cd - > /dev/null + +echo "Packed solution: ${OUTPUT_FILE}" +echo "Size: $(du -h "${OUTPUT_FILE}" | cut -f1)" diff --git a/skill/uipath-studio/scripts/solution_unpack.sh b/skill/uipath-studio/scripts/solution_unpack.sh new file mode 100755 index 0000000..24aecf7 --- /dev/null +++ b/skill/uipath-studio/scripts/solution_unpack.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Unpacks a .uis file (ZIP archive) into a directory. +# +# Usage: solution_unpack.sh [output_directory] +# +# If output_directory is not specified, creates a directory named after the +# .uis file (without extension). +# +# Example: solution_unpack.sh MySolution.uis +# solution_unpack.sh MySolution.uis ./my-output-dir + +set -euo pipefail + +UIS_FILE="${1:?Usage: solution_unpack.sh [output_directory]}" + +if [ ! -f "${UIS_FILE}" ]; then + echo "ERROR: File not found: ${UIS_FILE}" >&2 + exit 1 +fi + +# Determine output directory +if [ -n "${2:-}" ]; then + OUTPUT_DIR="$2" +else + BASENAME=$(basename "${UIS_FILE}" .uis) + OUTPUT_DIR="${BASENAME}" +fi + +# Create output directory +mkdir -p "${OUTPUT_DIR}" + +# Extract +unzip -o "${UIS_FILE}" -d "${OUTPUT_DIR}" + +# Validate +if [ ! -f "${OUTPUT_DIR}/SolutionStorage.json" ]; then + echo "WARNING: No SolutionStorage.json found. This may not be a valid .uis file." >&2 +fi + +echo "" +echo "Unpacked to: ${OUTPUT_DIR}/" + +# Show solution info +if [ -f "${OUTPUT_DIR}/SolutionStorage.json" ]; then + SOLUTION_ID=$(python3 -c "import json; d=json.load(open('${OUTPUT_DIR}/SolutionStorage.json')); print(d.get('SolutionId','unknown'))" 2>/dev/null || echo "unknown") + PROJECT_COUNT=$(python3 -c "import json; d=json.load(open('${OUTPUT_DIR}/SolutionStorage.json')); print(len(d.get('Projects',[])))" 2>/dev/null || echo "unknown") + echo "Solution ID: ${SOLUTION_ID}" + echo "Projects: ${PROJECT_COUNT}" +fi + +# List projects from .uipx if available +UIPX_FILE=$(find "${OUTPUT_DIR}" -maxdepth 1 -name "*.uipx" -type f | head -1) +if [ -n "${UIPX_FILE}" ]; then + echo "" + echo "Project types:" + python3 -c " +import json +d = json.load(open('${UIPX_FILE}')) +for p in d.get('Projects', []): + print(f\" - {p.get('Type', 'Unknown'):25s} {p.get('ProjectRelativePath', '')}\") +" 2>/dev/null || true +fi diff --git a/skill/uipath-studio/scripts/validate_agent.sh b/skill/uipath-studio/scripts/validate_agent.sh new file mode 100755 index 0000000..b04ab65 --- /dev/null +++ b/skill/uipath-studio/scripts/validate_agent.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Validates a UiPath agent project structure. +# +# Usage: validate_agent.sh +# +# Checks for required files, valid JSON, schema consistency, and common issues. +# +# Example: validate_agent.sh ./Agent +# validate_agent.sh ./MySolution/Agent + +set -euo pipefail + +AGENT_DIR="${1:?Usage: validate_agent.sh }" +AGENT_DIR="${AGENT_DIR%/}" + +ERRORS=0 +WARNINGS=0 + +error() { echo " ERROR: $1" >&2; ERRORS=$((ERRORS + 1)); } +warn() { echo " WARN: $1" >&2; WARNINGS=$((WARNINGS + 1)); } +ok() { echo " OK: $1"; } + +echo "Validating agent: ${AGENT_DIR}" +echo "==========================================" + +# 1. Check required files +echo "" +echo "Required files:" + +if [ -f "${AGENT_DIR}/agent.json" ]; then + ok "agent.json exists" +else + error "agent.json missing" +fi + +if [ -f "${AGENT_DIR}/entry-points.json" ]; then + ok "entry-points.json exists" +else + error "entry-points.json missing" +fi + +if [ -f "${AGENT_DIR}/project.uiproj" ]; then + ok "project.uiproj exists" +else + error "project.uiproj missing" +fi + +# 2. Validate JSON files +echo "" +echo "JSON validation:" + +for f in "${AGENT_DIR}/agent.json" "${AGENT_DIR}/entry-points.json" "${AGENT_DIR}/project.uiproj"; do + if [ -f "$f" ]; then + if python3 -c "import json; json.load(open('$f'))" 2>/dev/null; then + ok "$(basename "$f") is valid JSON" + else + error "$(basename "$f") is NOT valid JSON" + fi + fi +done + +# 3. Check project type +echo "" +echo "Project type:" + +if [ -f "${AGENT_DIR}/project.uiproj" ]; then + PROJECT_TYPE=$(python3 -c "import json; print(json.load(open('${AGENT_DIR}/project.uiproj')).get('ProjectType',''))" 2>/dev/null || echo "") + if [ "${PROJECT_TYPE}" = "Agent" ]; then + ok "ProjectType is 'Agent'" + else + error "ProjectType is '${PROJECT_TYPE}', expected 'Agent'" + fi +fi + +# 4. Check agent type and structure +echo "" +echo "Agent configuration:" + +if [ -f "${AGENT_DIR}/agent.json" ]; then + AGENT_TYPE=$(python3 -c "import json; print(json.load(open('${AGENT_DIR}/agent.json')).get('type',''))" 2>/dev/null || echo "") + + if [ "${AGENT_TYPE}" = "lowCode" ]; then + ok "Agent type: lowCode" + + # Check low-code specific files + if [ -d "${AGENT_DIR}/.agent-builder" ]; then + ok ".agent-builder/ directory exists" + else + warn ".agent-builder/ directory missing (recommended for low-code agents)" + fi + + # Check messages + MSG_COUNT=$(python3 -c "import json; d=json.load(open('${AGENT_DIR}/agent.json')); print(len(d.get('messages',[])))" 2>/dev/null || echo "0") + if [ "${MSG_COUNT}" -ge 2 ]; then + ok "Messages defined (${MSG_COUNT} messages)" + else + warn "Less than 2 messages defined (system + user recommended)" + fi + + elif [ "${AGENT_TYPE}" = "coded" ]; then + ok "Agent type: coded" + + # Check coded-specific files + if [ -f "${AGENT_DIR}/source_code/main.py" ]; then + ok "source_code/main.py exists" + else + error "source_code/main.py missing (required for coded agents)" + fi + + if [ -f "${AGENT_DIR}/source_code/pyproject.toml" ]; then + ok "source_code/pyproject.toml exists" + else + error "source_code/pyproject.toml missing" + fi + + if [ -f "${AGENT_DIR}/source_code/uipath.json" ]; then + ok "source_code/uipath.json exists" + else + warn "source_code/uipath.json missing" + fi + else + error "Unknown agent type: '${AGENT_TYPE}'" + fi + + # Check model configuration + MODEL=$(python3 -c "import json; d=json.load(open('${AGENT_DIR}/agent.json')); print(d.get('settings',{}).get('model',''))" 2>/dev/null || echo "") + if [ -n "${MODEL}" ]; then + ok "Model configured: ${MODEL}" + else + warn "No model configured in settings" + fi + + # Check schemas + HAS_INPUT=$(python3 -c "import json; d=json.load(open('${AGENT_DIR}/agent.json')); print('yes' if d.get('inputSchema') else 'no')" 2>/dev/null || echo "no") + HAS_OUTPUT=$(python3 -c "import json; d=json.load(open('${AGENT_DIR}/agent.json')); print('yes' if d.get('outputSchema') else 'no')" 2>/dev/null || echo "no") + + if [ "${HAS_INPUT}" = "yes" ]; then + ok "inputSchema defined" + else + warn "No inputSchema defined" + fi + if [ "${HAS_OUTPUT}" = "yes" ]; then + ok "outputSchema defined" + else + warn "No outputSchema defined" + fi +fi + +# 5. Check resources/tools +echo "" +echo "Resources/Tools:" + +if [ -d "${AGENT_DIR}/resources" ]; then + RESOURCE_COUNT=$(find "${AGENT_DIR}/resources" -name "resource.json" -type f | wc -l) + ok "${RESOURCE_COUNT} resource(s) found" + + for res in "${AGENT_DIR}/resources"/*/resource.json; do + if [ -f "$res" ]; then + RES_NAME=$(python3 -c "import json; print(json.load(open('$res')).get('name','unknown'))" 2>/dev/null || echo "unknown") + RES_TYPE=$(python3 -c "import json; print(json.load(open('$res')).get('\$resourceType','unknown'))" 2>/dev/null || echo "unknown") + ok " ${RES_NAME} (${RES_TYPE})" + fi + done +else + ok "No resources directory (agent has no tools)" +fi + +# 6. Check evaluations +echo "" +echo "Evaluations:" + +for eval_dir in "${AGENT_DIR}/evals" "${AGENT_DIR}/coded-evals"; do + if [ -d "${eval_dir}" ]; then + EVAL_SET_COUNT=$(find "${eval_dir}/eval-sets" -name "*.json" -type f 2>/dev/null | wc -l) + EVALUATOR_COUNT=$(find "${eval_dir}/evaluators" -name "*.json" -type f 2>/dev/null | wc -l) + ok "$(basename "${eval_dir}")/: ${EVAL_SET_COUNT} eval set(s), ${EVALUATOR_COUNT} evaluator(s)" + fi +done + +if [ ! -d "${AGENT_DIR}/evals" ] && [ ! -d "${AGENT_DIR}/coded-evals" ]; then + warn "No evaluation sets found" +fi + +# Summary +echo "" +echo "==========================================" +if [ ${ERRORS} -eq 0 ] && [ ${WARNINGS} -eq 0 ]; then + echo "PASS: Agent structure is valid" +elif [ ${ERRORS} -eq 0 ]; then + echo "PASS with ${WARNINGS} warning(s)" +else + echo "FAIL: ${ERRORS} error(s), ${WARNINGS} warning(s)" + exit 1 +fi diff --git a/utils/api/studio_client.go b/utils/api/studio_client.go new file mode 100644 index 0000000..87b7cee --- /dev/null +++ b/utils/api/studio_client.go @@ -0,0 +1,284 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + + "github.com/UiPath/uipathcli/auth" + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils/converter" + "github.com/UiPath/uipathcli/utils/network" + "github.com/UiPath/uipathcli/utils/stream" + "github.com/UiPath/uipathcli/utils/visualization" +) + +// StudioClient is an HTTP client for the Studio Web backend API. +type StudioClient struct { + baseUri url.URL + organization string + token *auth.AuthToken + debug bool + settings plugin.ExecutionSettings + logger log.Logger +} + +// PushSolution uploads a .uis file to Studio Web. +func (c StudioClient) PushSolution(file stream.Stream, solutionId string, uploadBar *visualization.ProgressBar) (*PushSolutionResponse, error) { + ctx, cancel := context.WithCancelCause(context.Background()) + request := c.createPushSolutionRequest(file, solutionId, uploadBar, cancel) + client := network.NewHttpClient(c.logger, c.httpClientSettings()) + response, err := client.SendWithContext(request, ctx) + if err != nil { + return nil, err + } + defer func() { _ = response.Body.Close() }() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("Error reading response: %w", err) + } + if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("Studio Web returned status code '%v' and body '%v'", response.StatusCode, string(body)) + } + var result PushSolutionResponse + err = json.Unmarshal(body, &result) + if err != nil { + return &PushSolutionResponse{}, nil + } + return &result, nil +} + +func (c StudioClient) createPushSolutionRequest(file stream.Stream, solutionId string, uploadBar *visualization.ProgressBar, cancel context.CancelCauseFunc) *network.HttpRequest { + bodyReader, bodyWriter := io.Pipe() + streamSize, _ := file.Size() + contentType := c.writeMultipartBody(bodyWriter, file, "application/octet-stream", cancel) + uploadReader := c.progressReader("uploading...", "completing ", bodyReader, streamSize, uploadBar) + + uriBuilder := c.newUriBuilder("/api/v1/ExternalSolution/Push") + if solutionId != "" { + uriBuilder.AddQueryString("solutionId", solutionId) + } + uri := uriBuilder.Build() + header := http.Header{ + "Content-Type": {contentType}, + } + return network.NewHttpPostRequest(uri, c.toAuthorization(c.token), header, uploadReader, -1) +} + +// PullSolution downloads a solution from Studio Web as a .uis file. +func (c StudioClient) PullSolution(solutionId string) (io.ReadCloser, error) { + request := c.createPullSolutionRequest(solutionId) + client := network.NewHttpClient(c.logger, c.httpClientSettings()) + response, err := client.Send(request) + if err != nil { + return nil, err + } + if response.StatusCode != http.StatusOK { + defer func() { _ = response.Body.Close() }() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("Error reading response: %w", err) + } + return nil, fmt.Errorf("Studio Web returned status code '%v' and body '%v'", response.StatusCode, string(body)) + } + return response.Body, nil +} + +func (c StudioClient) createPullSolutionRequest(solutionId string) *network.HttpRequest { + uri := c.newUriBuilder("/api/v1/ExternalSolution/Pull"). + AddQueryString("solutionId", solutionId). + Build() + header := http.Header{ + "Accept": {"application/octet-stream"}, + } + return network.NewHttpGetRequest(uri, c.toAuthorization(c.token), header) +} + +// ListSolutions retrieves the list of solutions from Studio Web. +func (c StudioClient) ListSolutions() ([]SolutionInfo, error) { + request := c.createListSolutionsRequest() + client := network.NewHttpClient(c.logger, c.httpClientSettings()) + response, err := client.Send(request) + if err != nil { + return nil, err + } + defer func() { _ = response.Body.Close() }() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("Error reading response: %w", err) + } + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Studio Web returned status code '%v' and body '%v'", response.StatusCode, string(body)) + } + var result []SolutionInfo + err = json.Unmarshal(body, &result) + if err != nil { + return nil, fmt.Errorf("Studio Web returned invalid response body '%v'", string(body)) + } + return result, nil +} + +func (c StudioClient) createListSolutionsRequest() *network.HttpRequest { + uri := c.newUriBuilder("/api/v1/ExternalSolution/List").Build() + header := http.Header{ + "Content-Type": {"application/json"}, + } + return network.NewHttpGetRequest(uri, c.toAuthorization(c.token), header) +} + +// PublishSolution publishes a solution for deployment. +func (c StudioClient) PublishSolution(solutionId string) (*PublishSolutionResponse, error) { + requestBody, err := json.Marshal(publishSolutionRequestJson{ + SolutionId: solutionId, + }) + if err != nil { + return nil, err + } + + uri := c.newUriBuilder("/api/v1/Publish-Requests").Build() + header := http.Header{ + "Content-Type": {"application/json"}, + } + request := network.NewHttpPostRequest(uri, c.toAuthorization(c.token), header, bytes.NewBuffer(requestBody), -1) + client := network.NewHttpClient(c.logger, c.httpClientSettings()) + response, err := client.Send(request) + if err != nil { + return nil, err + } + defer func() { _ = response.Body.Close() }() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("Error reading response: %w", err) + } + if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusAccepted { + return nil, fmt.Errorf("Studio Web returned status code '%v' and body '%v'", response.StatusCode, string(body)) + } + var result PublishSolutionResponse + err = json.Unmarshal(body, &result) + if err != nil { + return &PublishSolutionResponse{}, nil + } + return &result, nil +} + +func (c StudioClient) writeMultipartBody(bodyWriter *io.PipeWriter, stream stream.Stream, contentType string, cancel context.CancelCauseFunc) string { + formWriter := multipart.NewWriter(bodyWriter) + go func() { + defer func() { _ = bodyWriter.Close() }() + defer func() { _ = formWriter.Close() }() + err := c.writeMultipartForm(formWriter, stream, contentType) + if err != nil { + cancel(err) + return + } + }() + return formWriter.FormDataContentType() +} + +func (c StudioClient) writeMultipartForm(writer *multipart.Writer, stream stream.Stream, contentType string) error { + filePart := textproto.MIMEHeader{} + filePart.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, stream.Name())) + filePart.Set("Content-Type", contentType) + w, err := writer.CreatePart(filePart) + if err != nil { + return fmt.Errorf("Error creating form field 'file': %w", err) + } + data, err := stream.Data() + if err != nil { + return err + } + defer func() { _ = data.Close() }() + _, err = io.Copy(w, data) + if err != nil { + return fmt.Errorf("Error writing form field 'file': %w", err) + } + return nil +} + +func (c StudioClient) progressReader(text string, completedText string, reader io.Reader, length int64, progressBar *visualization.ProgressBar) io.Reader { + if progressBar == nil || length < 10*1024*1024 { + return reader + } + return visualization.NewProgressReader(reader, func(progress visualization.Progress) { + displayText := text + if progress.Completed { + displayText = completedText + } + progressBar.UpdateProgress(displayText, progress.BytesRead, length, progress.BytesPerSecond) + }) +} + +func (c StudioClient) httpClientSettings() network.HttpClientSettings { + return *network.NewHttpClientSettings( + c.debug, + c.settings.OperationId, + c.settings.Header, + c.settings.Timeout, + c.settings.MaxAttempts, + c.settings.Insecure) +} + +func (c StudioClient) newUriBuilder(path string) *converter.UriBuilder { + baseUri := c.baseUri + if baseUri.Path == "" { + baseUri.Path = "/{organization}/studio_/backend" + } + return converter.NewUriBuilder(baseUri, path). + FormatPath("organization", c.organization) +} + +func (c StudioClient) toAuthorization(token *auth.AuthToken) *network.Authorization { + if token == nil { + return nil + } + return network.NewAuthorization(token.Type, token.Value) +} + +type publishSolutionRequestJson struct { + SolutionId string `json:"solutionId"` +} + +// PushSolutionResponse is the response from pushing a solution. +type PushSolutionResponse struct { + SolutionId string `json:"solutionId"` + Status string `json:"status"` +} + +// PublishSolutionResponse is the response from publishing a solution. +type PublishSolutionResponse struct { + RequestId string `json:"requestId"` + Status string `json:"status"` +} + +// SolutionInfo describes a solution returned from List. +type SolutionInfo struct { + SolutionId string `json:"solutionId"` + Name string `json:"name"` + Status string `json:"status"` +} + +// NewStudioClient creates a new Studio Web API client. +func NewStudioClient( + baseUri url.URL, + organization string, + token *auth.AuthToken, + debug bool, + settings plugin.ExecutionSettings, + logger log.Logger, +) *StudioClient { + return &StudioClient{ + baseUri, + organization, + token, + debug, + settings, + logger, + } +} diff --git a/utils/api/studio_client_test.go b/utils/api/studio_client_test.go new file mode 100644 index 0000000..852117f --- /dev/null +++ b/utils/api/studio_client_test.go @@ -0,0 +1,458 @@ +package api + +import ( + "bytes" + "errors" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/UiPath/uipathcli/auth" + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils/stream" + "github.com/UiPath/uipathcli/utils/visualization" +) + +func newTestClient(t *testing.T, serverURL string, token *auth.AuthToken) *StudioClient { + t.Helper() + baseUri, _ := url.Parse(serverURL) + logger := log.NewDefaultLogger(io.Discard) + settings := plugin.ExecutionSettings{ + MaxAttempts: 1, + } + return NewStudioClient(*baseUri, "my-org", token, false, settings, logger) +} + +func TestToAuthorizationWithNilToken(t *testing.T) { + client := newTestClient(t, "http://localhost", nil) + + result := client.toAuthorization(nil) + + if result != nil { + t.Errorf("Expected nil authorization for nil token, but got: %v", result) + } +} + +func TestToAuthorizationWithToken(t *testing.T) { + token := &auth.AuthToken{Type: "Bearer", Value: "test-token"} + client := newTestClient(t, "http://localhost", token) + + result := client.toAuthorization(token) + + if result == nil { + t.Errorf("Expected authorization for valid token, but got nil") + } +} + +func TestNewUriBuilderWithEmptyPath(t *testing.T) { + client := newTestClient(t, "http://localhost", nil) + + builder := client.newUriBuilder("/api/v1/test") + uri := builder.Build() + + if !strings.Contains(uri, "/my-org/studio_/backend/api/v1/test") { + t.Errorf("Expected URI to contain default studio backend path, but got: %v", uri) + } +} + +func TestNewUriBuilderWithCustomPath(t *testing.T) { + baseUri, _ := url.Parse("http://localhost/custom/path") + logger := log.NewDefaultLogger(io.Discard) + settings := plugin.ExecutionSettings{} + client := NewStudioClient(*baseUri, "my-org", nil, false, settings, logger) + + builder := client.newUriBuilder("/api/v1/test") + uri := builder.Build() + + if !strings.Contains(uri, "/custom/path/api/v1/test") { + t.Errorf("Expected URI to use custom path, but got: %v", uri) + } +} + +func TestProgressReaderWithNilProgressBar(t *testing.T) { + client := newTestClient(t, "http://localhost", nil) + reader := strings.NewReader("test-data") + + result := client.progressReader("uploading", "done", reader, 100*1024*1024, nil) + + if result != reader { + t.Errorf("Expected original reader when progressBar is nil") + } +} + +func TestProgressReaderWithSmallFile(t *testing.T) { + client := newTestClient(t, "http://localhost", nil) + reader := strings.NewReader("test-data") + logger := log.NewDefaultLogger(io.Discard) + + result := client.progressReader("uploading", "done", reader, 100, newTestProgressBar(logger)) + + if result != reader { + t.Errorf("Expected original reader for small files") + } +} + +func TestProgressReaderWithLargeFile(t *testing.T) { + client := newTestClient(t, "http://localhost", nil) + reader := strings.NewReader("test-data") + logger := log.NewDefaultLogger(io.Discard) + + result := client.progressReader("uploading", "done", reader, 20*1024*1024, newTestProgressBar(logger)) + + if result == reader { + t.Errorf("Expected wrapped reader for large files, but got original reader") + } + // Verify the wrapped reader still returns data + data, err := io.ReadAll(result) + if err != nil { + t.Errorf("Expected no error reading from wrapped reader, but got: %v", err) + } + if string(data) != "test-data" { + t.Errorf("Expected test-data, but got: %v", string(data)) + } +} + +func TestListSolutionsWithNilToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[{"solutionId":"sol-1","name":"Test","status":"active"}]`)) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + solutions, err := client.ListSolutions() + + if err != nil { + t.Errorf("Expected no error, but got: %v", err) + } + if len(solutions) != 1 || solutions[0].SolutionId != "sol-1" { + t.Errorf("Expected 1 solution with id sol-1, but got: %v", solutions) + } +} + +func TestPullSolutionWithNilToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("solution-data")) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + body, err := client.PullSolution("test-id") + + if err != nil { + t.Errorf("Expected no error, but got: %v", err) + } + defer func() { _ = body.Close() }() + data, _ := io.ReadAll(body) + if string(data) != "solution-data" { + t.Errorf("Expected solution-data, but got: %v", string(data)) + } +} + +func TestPushSolutionWithNilToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"solutionId":"pushed-id","status":"ok"}`)) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + file := stream.NewMemoryStream("test.uis", []byte("test-content")) + result, err := client.PushSolution(file, "", nil) + + if err != nil { + t.Errorf("Expected no error, but got: %v", err) + } + if result.SolutionId != "pushed-id" { + t.Errorf("Expected solutionId pushed-id, but got: %v", result.SolutionId) + } +} + +func TestPublishSolutionWithNilToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"requestId":"req-1","status":"queued"}`)) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + result, err := client.PublishSolution("test-id") + + if err != nil { + t.Errorf("Expected no error, but got: %v", err) + } + if result.RequestId != "req-1" { + t.Errorf("Expected requestId req-1, but got: %v", result.RequestId) + } +} + +func TestPushSolutionWithSolutionId(t *testing.T) { + var requestURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestURL = r.URL.String() + _, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"solutionId":"existing-id"}`)) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + file := stream.NewMemoryStream("test.uis", []byte("data")) + _, err := client.PushSolution(file, "existing-id", nil) + + if err != nil { + t.Errorf("Expected no error, but got: %v", err) + } + if !strings.Contains(requestURL, "solutionId=existing-id") { + t.Errorf("Expected solutionId query param, but URL was: %v", requestURL) + } +} + +func TestPushSolutionNonJsonResponseReturnsEmptyResult(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json-response")) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + file := stream.NewMemoryStream("test.uis", []byte("content")) + result, err := client.PushSolution(file, "", nil) + + if err != nil { + t.Errorf("Expected no error for non-JSON response, but got: %v", err) + } + if result.SolutionId != "" { + t.Errorf("Expected empty solutionId for non-JSON response, but got: %v", result.SolutionId) + } +} + +func TestPublishSolutionNonJsonResponseReturnsEmptyResult(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + result, err := client.PublishSolution("test-id") + + if err != nil { + t.Errorf("Expected no error for non-JSON response, but got: %v", err) + } + if result.RequestId != "" { + t.Errorf("Expected empty requestId for non-JSON response, but got: %v", result.RequestId) + } +} + +func TestListSolutionsInvalidJsonReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + _, err := client.ListSolutions() + + if err == nil || !strings.Contains(err.Error(), "invalid response body") { + t.Errorf("Expected invalid response body error, but got: %v", err) + } +} + +func TestListSolutionsBadRequestReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"bad request"}`)) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + _, err := client.ListSolutions() + + if err == nil || !strings.Contains(err.Error(), "400") { + t.Errorf("Expected error with status code 400, but got: %v", err) + } +} + +func TestPullSolutionServerErrorReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"not found"}`)) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + _, err := client.PullSolution("missing-id") + + if err == nil || !strings.Contains(err.Error(), "404") { + t.Errorf("Expected error with status code 404, but got: %v", err) + } +} + +func TestPushSolutionServerErrorReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"bad request"}`)) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + file := stream.NewMemoryStream("test.uis", []byte("content")) + _, err := client.PushSolution(file, "", nil) + + if err == nil || !strings.Contains(err.Error(), "400") { + t.Errorf("Expected error with status code 400, but got: %v", err) + } +} + +func TestPublishSolutionServerErrorReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + _, err := client.PublishSolution("test-id") + + if err == nil || !strings.Contains(err.Error(), "403") { + t.Errorf("Expected error with status code 403, but got: %v", err) + } +} + +func TestHttpClientSettings(t *testing.T) { + baseUri, _ := url.Parse("http://localhost") + logger := log.NewDefaultLogger(io.Discard) + settings := plugin.ExecutionSettings{ + OperationId: "test-op", + Insecure: true, + } + client := NewStudioClient(*baseUri, "my-org", nil, true, settings, logger) + + result := client.httpClientSettings() + + if !result.Debug { + t.Errorf("Expected debug to be true") + } +} + +func TestWriteMultipartForm(t *testing.T) { + client := newTestClient(t, "http://localhost", nil) + file := stream.NewMemoryStream("test.uis", []byte("file-content")) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + err := client.writeMultipartForm(writer, file, "application/octet-stream") + _ = writer.Close() + + if err != nil { + t.Errorf("Expected no error, but got: %v", err) + } + if !strings.Contains(buf.String(), "file-content") { + t.Errorf("Expected multipart body to contain file content") + } + if !strings.Contains(buf.String(), "test.uis") { + t.Errorf("Expected multipart body to contain filename") + } +} + +func TestWriteMultipartFormWithFailingStream(t *testing.T) { + client := newTestClient(t, "http://localhost", nil) + file := &failingStream{name: "test.uis"} + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + err := client.writeMultipartForm(writer, file, "application/octet-stream") + + if err == nil { + t.Errorf("Expected error from failing stream, but got nil") + } +} + +type failingStream struct { + name string +} + +func (s *failingStream) Name() string { return s.name } +func (s *failingStream) Size() (int64, error) { return 0, errors.New("size error") } +func (s *failingStream) Data() (io.ReadCloser, error) { + return nil, errors.New("data error") +} + +func TestWriteMultipartFormWithFailingWriter(t *testing.T) { + client := newTestClient(t, "http://localhost", nil) + file := stream.NewMemoryStream("test.uis", []byte("content")) + + writer := multipart.NewWriter(&failingWriter{}) + err := client.writeMultipartForm(writer, file, "application/octet-stream") + + if err == nil || !strings.Contains(err.Error(), "Error creating form field") { + t.Errorf("Expected error creating form field, but got: %v", err) + } +} + +func TestWriteMultipartFormWithReadError(t *testing.T) { + client := newTestClient(t, "http://localhost", nil) + file := &failingReadStream{name: "test.uis"} + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + err := client.writeMultipartForm(writer, file, "application/octet-stream") + + if err == nil || !strings.Contains(err.Error(), "Error writing form field") { + t.Errorf("Expected error writing form field, but got: %v", err) + } +} + +func TestPushSolutionWithFailingStreamCancelsRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"solutionId":"test"}`)) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + file := &failingStream{name: "test.uis"} + _, err := client.PushSolution(file, "", nil) + + if err == nil { + t.Errorf("Expected error from failing stream push, but got nil") + } +} + +type failingWriter struct{} + +func (w *failingWriter) Write(_ []byte) (int, error) { + return 0, errors.New("write error") +} + +type failingReadStream struct { + name string +} + +func (s *failingReadStream) Name() string { return s.name } +func (s *failingReadStream) Size() (int64, error) { return 100, nil } +func (s *failingReadStream) Data() (io.ReadCloser, error) { + return io.NopCloser(&failingReader{}), nil +} + +type failingReader struct{} + +func (r *failingReader) Read(_ []byte) (int, error) { + return 0, errors.New("read error") +} + +func newTestProgressBar(logger log.Logger) *visualization.ProgressBar { + return visualization.NewProgressBar(logger) +}