From 41ee1db535f753a9a6c87aafceb5458106dc5464 Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 13:26:35 -0700 Subject: [PATCH 01/16] Add studio solution commands, Claude Code skill, and agents.md reference Implement 6 new CLI plugin commands under `uipath studio solution` for managing UiPath Maestro solutions: pack, unpack, push, pull, list, and publish. Add a Studio Web API client for HTTP calls to the studio backend. Include a comprehensive Claude Code skill (SKILL.md with references, examples, and scripts) for agent/solution development, and an agents.md coding agent reference documenting all CLI capabilities. Co-Authored-By: Claude Opus 4.6 --- agents.md | 772 ++++++++++++++++++ main.go | 12 + .../solution/list/solution_list_command.go | 52 ++ .../list/solution_list_command_test.go | 54 ++ .../solution/list/solution_list_result.go | 8 + .../solution/pack/solution_pack_command.go | 182 +++++ .../pack/solution_pack_command_test.go | 246 ++++++ .../solution/pack/solution_pack_params.go | 12 + .../solution/pack/solution_pack_result.go | 14 + .../publish/solution_publish_command.go | 71 ++ .../publish/solution_publish_command_test.go | 89 ++ .../publish/solution_publish_result.go | 7 + .../solution/pull/solution_pull_command.go | 99 +++ .../pull/solution_pull_command_test.go | 106 +++ .../solution/pull/solution_pull_params.go | 28 + .../solution/pull/solution_pull_result.go | 13 + .../solution/push/solution_push_command.go | 92 +++ .../push/solution_push_command_test.go | 117 +++ .../solution/push/solution_push_params.go | 28 + .../solution/push/solution_push_result.go | 12 + .../unpack/solution_unpack_command.go | 151 ++++ .../unpack/solution_unpack_command_test.go | 115 +++ .../solution/unpack/solution_unpack_params.go | 10 + .../solution/unpack/solution_unpack_result.go | 13 + skill/uipath-studio/SKILL.md | 199 +++++ .../examples/coded-agent/Agent/agent.json | 33 + .../Agent/coded-evals/eval-sets/default.json | 50 ++ .../coded-evals/evaluators/contains.json | 15 + .../evaluators/custom/source_counter.py | 49 ++ .../custom/types/source-counter-types.json | 10 + .../coded-evals/evaluators/exact-match.json | 17 + .../coded-evals/evaluators/trajectory.json | 15 + .../coded-agent/Agent/entry-points.json | 27 + .../examples/coded-agent/Agent/project.uiproj | 6 + .../coded-agent/Agent/source_code/main.py | 78 ++ .../Agent/source_code/pyproject.toml | 7 + .../coded-agent/Agent/source_code/uipath.json | 29 + .../Agent/.agent-builder/agent.json | 53 ++ .../Agent/.agent-builder/bindings.json | 4 + .../Agent/.agent-builder/entry-points.json | 23 + .../Agent/.project/JitCustomTypes.json | 1 + .../examples/low-code-agent/Agent/agent.json | 57 ++ .../low-code-agent/Agent/entry-points.json | 23 + .../eval-sets/evaluation-set-default.json | 29 + .../evaluator-default-trajectory.json | 15 + .../evals/evaluators/evaluator-default.json | 18 + .../low-code-agent/Agent/flow-layout.json | 1 + .../low-code-agent/Agent/project.uiproj | 6 + .../Agent/resources/Web Search/resource.json | 109 +++ .../examples/solution/MySolution.uipx | 12 + .../examples/solution/SolutionStorage.json | 9 + .../solution_folder/package/Agent.json | 21 + .../solution_folder/process/agent/Agent.json | 39 + .../references/agent-structure.md | 330 ++++++++ .../references/cli-architecture.md | 268 ++++++ .../references/evaluation-framework.md | 407 +++++++++ .../references/solution-structure.md | 361 ++++++++ .../references/studio-web-api.md | 391 +++++++++ skill/uipath-studio/references/tool-types.md | 393 +++++++++ .../uipath-studio/scripts/solution_create.sh | 281 +++++++ skill/uipath-studio/scripts/solution_pack.sh | 53 ++ .../uipath-studio/scripts/solution_unpack.sh | 62 ++ skill/uipath-studio/scripts/validate_agent.sh | 194 +++++ utils/api/studio_client.go | 284 +++++++ 64 files changed, 6282 insertions(+) create mode 100644 agents.md create mode 100644 plugin/studio/solution/list/solution_list_command.go create mode 100644 plugin/studio/solution/list/solution_list_command_test.go create mode 100644 plugin/studio/solution/list/solution_list_result.go create mode 100644 plugin/studio/solution/pack/solution_pack_command.go create mode 100644 plugin/studio/solution/pack/solution_pack_command_test.go create mode 100644 plugin/studio/solution/pack/solution_pack_params.go create mode 100644 plugin/studio/solution/pack/solution_pack_result.go create mode 100644 plugin/studio/solution/publish/solution_publish_command.go create mode 100644 plugin/studio/solution/publish/solution_publish_command_test.go create mode 100644 plugin/studio/solution/publish/solution_publish_result.go create mode 100644 plugin/studio/solution/pull/solution_pull_command.go create mode 100644 plugin/studio/solution/pull/solution_pull_command_test.go create mode 100644 plugin/studio/solution/pull/solution_pull_params.go create mode 100644 plugin/studio/solution/pull/solution_pull_result.go create mode 100644 plugin/studio/solution/push/solution_push_command.go create mode 100644 plugin/studio/solution/push/solution_push_command_test.go create mode 100644 plugin/studio/solution/push/solution_push_params.go create mode 100644 plugin/studio/solution/push/solution_push_result.go create mode 100644 plugin/studio/solution/unpack/solution_unpack_command.go create mode 100644 plugin/studio/solution/unpack/solution_unpack_command_test.go create mode 100644 plugin/studio/solution/unpack/solution_unpack_params.go create mode 100644 plugin/studio/solution/unpack/solution_unpack_result.go create mode 100644 skill/uipath-studio/SKILL.md create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/agent.json create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/coded-evals/eval-sets/default.json create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/contains.json create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/custom/source_counter.py create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/custom/types/source-counter-types.json create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/exact-match.json create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/coded-evals/evaluators/trajectory.json create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/entry-points.json create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/project.uiproj create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/source_code/main.py create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/source_code/pyproject.toml create mode 100644 skill/uipath-studio/examples/coded-agent/Agent/source_code/uipath.json create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/agent.json create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/bindings.json create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/.agent-builder/entry-points.json create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/.project/JitCustomTypes.json create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/agent.json create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/entry-points.json create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/evals/eval-sets/evaluation-set-default.json create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/evals/evaluators/evaluator-default-trajectory.json create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/evals/evaluators/evaluator-default.json create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/flow-layout.json create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/project.uiproj create mode 100644 skill/uipath-studio/examples/low-code-agent/Agent/resources/Web Search/resource.json create mode 100644 skill/uipath-studio/examples/solution/MySolution.uipx create mode 100644 skill/uipath-studio/examples/solution/SolutionStorage.json create mode 100644 skill/uipath-studio/examples/solution/resources/solution_folder/package/Agent.json create mode 100644 skill/uipath-studio/examples/solution/resources/solution_folder/process/agent/Agent.json create mode 100644 skill/uipath-studio/references/agent-structure.md create mode 100644 skill/uipath-studio/references/cli-architecture.md create mode 100644 skill/uipath-studio/references/evaluation-framework.md create mode 100644 skill/uipath-studio/references/solution-structure.md create mode 100644 skill/uipath-studio/references/studio-web-api.md create mode 100644 skill/uipath-studio/references/tool-types.md create mode 100755 skill/uipath-studio/scripts/solution_create.sh create mode 100755 skill/uipath-studio/scripts/solution_pack.sh create mode 100755 skill/uipath-studio/scripts/solution_unpack.sh create mode 100755 skill/uipath-studio/scripts/validate_agent.sh create mode 100644 utils/api/studio_client.go 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..805ecd0 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,12 @@ 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_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 +84,12 @@ func main() { plugin_studio_restore.NewPackageRestoreCommand(), plugin_studio_publish.NewPackagePublishCommand(), plugin_studio_testrun.NewTestRunCommand(), + 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/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..3a82d96 --- /dev/null +++ b/plugin/studio/solution/list/solution_list_command_test.go @@ -0,0 +1,54 @@ +package list + +import ( + "net/http" + "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 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..366d48f --- /dev/null +++ b/plugin/studio/solution/pack/solution_pack_command.go @@ -0,0 +1,182 @@ +// 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" + "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 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 + } + + // Skip .git directory + if strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) || relPath == ".git" { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + // Skip __pycache__ directories + if info.IsDir() && info.Name() == "__pycache__" { + return filepath.SkipDir + } + + // Skip .pyc files + if !info.IsDir() && strings.HasSuffix(info.Name(), ".pyc") { + return nil + } + + if info.IsDir() { + return nil + } + + // Use forward slashes in ZIP entries + 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 + }) + if err != nil { + // Clean up partial output on failure + _ = zipWriter.Close() + _ = outFile.Close() + _ = os.Remove(params.Destination) + return nil, err + } + + 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) 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 errors.Is(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..d28ee5e --- /dev/null +++ b/plugin/studio/solution/pack/solution_pack_command_test.go @@ -0,0 +1,246 @@ +package pack + +import ( + "archive/zip" + "encoding/json" + "os" + "path/filepath" + "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, 0755) + _ = 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, 0755) + _ = 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() + origDir, _ := os.Getwd() + _ = os.Chdir(tmpDir) + defer func() { _ = os.Chdir(origDir) }() + + 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 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, 0755) + _ = 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, 0755) + _ = os.WriteFile(filepath.Join(builderDir, "bindings.json"), []byte(`{"version":"2.0","resources":[]}`), 0600) + + projectDir := filepath.Join(agentDir, ".project") + _ = os.MkdirAll(projectDir, 0755) + _ = 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..ec73d62 --- /dev/null +++ b/plugin/studio/solution/publish/solution_publish_command_test.go @@ -0,0 +1,89 @@ +package publish + +import ( + "net/http" + "strings" + "testing" + + "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 || !strings.Contains(result.Error.Error(), "Solution ID is required") { + t.Errorf("Expected solution id required error, but got: %v", result.Error) + } +} + +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 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") + } +} 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..4a7cead --- /dev/null +++ b/plugin/studio/solution/pull/solution_pull_command_test.go @@ -0,0 +1,106 @@ +package pull + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "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 || !strings.Contains(result.Error.Error(), "Solution ID is required") { + t.Errorf("Expected solution id required error, but got: %v", result.Error) + } +} + +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() + origDir, _ := os.Getwd() + _ = os.Chdir(tmpDir) + defer func() { _ = os.Chdir(origDir) }() + + 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 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") + } +} 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..5a5dda2 --- /dev/null +++ b/plugin/studio/solution/push/solution_push_command_test.go @@ -0,0 +1,117 @@ +package push + +import ( + "net/http" + "strings" + "testing" + + "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 || !strings.Contains(result.Error.Error(), "Source .uis file is required") { + t.Errorf("Expected source required error, but got: %v", result.Error) + } +} + +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 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") + } +} 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..d7db090 --- /dev/null +++ b/plugin/studio/solution/unpack/solution_unpack_command.go @@ -0,0 +1,151 @@ +// 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" + "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 fmt.Errorf("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 { + destPath := filepath.Join(params.Destination, file.Name) //nolint:gosec // paths within trusted .uis archive + + // Validate path doesn't escape destination (zip slip protection) + if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(params.Destination)+string(filepath.Separator)) && + filepath.Clean(destPath) != filepath.Clean(params.Destination) { + return nil, fmt.Errorf("Invalid file path in archive: %s", file.Name) + } + + if file.FileInfo().IsDir() { + err := os.MkdirAll(destPath, 0755) + if err != nil { + return nil, fmt.Errorf("Cannot create directory '%s': %w", destPath, err) + } + continue + } + + err := os.MkdirAll(filepath.Dir(destPath), 0755) + if err != nil { + return nil, fmt.Errorf("Cannot create directory for '%s': %w", destPath, err) + } + + outFile, err := os.Create(destPath) + if err != nil { + return nil, fmt.Errorf("Cannot create file '%s': %w", destPath, err) + } + + rc, err := file.Open() + if err != nil { + _ = outFile.Close() + return nil, fmt.Errorf("Cannot read archive entry '%s': %w", file.Name, err) + } + + _, err = io.Copy(outFile, rc) + _ = rc.Close() + _ = outFile.Close() + if err != nil { + return nil, fmt.Errorf("Error extracting '%s': %w", file.Name, err) + } + } + + solutionId, projectCount := c.readSolutionInfo(filepath.Join(params.Destination, "SolutionStorage.json")) + + return newSucceededSolutionUnpackResult(params.Destination, solutionId, projectCount), nil +} + +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..f41ead7 --- /dev/null +++ b/plugin/studio/solution/unpack/solution_unpack_command_test.go @@ -0,0 +1,115 @@ +package unpack + +import ( + "archive/zip" + "os" + "path/filepath" + "strings" + "testing" + + "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 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, + } +} From 0a978bbe5f2e9cb8a0f172b0afde12540127eaf4 Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 13:33:23 -0700 Subject: [PATCH 02/16] Fix CI lint and test failures for studio solution commands - Reduce pack() cyclomatic complexity by extracting shouldSkip/addFileToZip helpers - Fix gosec G301: use 0750 directory permissions instead of 0755 - Fix gosec G110: use io.LimitReader to prevent decompression bomb - Fix perfsprint: use errors.New instead of fmt.Errorf for static strings - Fix usetesting: use t.Chdir() instead of os.Chdir() in tests - Fix test assertions: check for error existence rather than custom messages when CLI framework validates required parameters before Execute runs - Fix errors.Is(err, nil) to err == nil in readSolutionInfo Co-Authored-By: Claude Opus 4.6 --- .../solution/pack/solution_pack_command.go | 71 +++++++++---------- .../pack/solution_pack_command_test.go | 14 ++-- .../publish/solution_publish_command_test.go | 4 +- .../pull/solution_pull_command_test.go | 8 +-- .../push/solution_push_command_test.go | 4 +- .../unpack/solution_unpack_command.go | 10 +-- 6 files changed, 53 insertions(+), 58 deletions(-) diff --git a/plugin/studio/solution/pack/solution_pack_command.go b/plugin/studio/solution/pack/solution_pack_command.go index 366d48f..691741f 100644 --- a/plugin/studio/solution/pack/solution_pack_command.go +++ b/plugin/studio/solution/pack/solution_pack_command.go @@ -6,7 +6,6 @@ import ( "archive/zip" "bytes" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -85,55 +84,22 @@ func (c SolutionPackCommand) pack(params solutionPackParams) (*solutionPackResul if err != nil { return err } - relPath, err := filepath.Rel(params.Source, path) if err != nil { return err } - - // Skip .git directory - if strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) || relPath == ".git" { + if c.shouldSkip(relPath, info) { if info.IsDir() { return filepath.SkipDir } return nil } - - // Skip __pycache__ directories - if info.IsDir() && info.Name() == "__pycache__" { - return filepath.SkipDir - } - - // Skip .pyc files - if !info.IsDir() && strings.HasSuffix(info.Name(), ".pyc") { - return nil - } - if info.IsDir() { return nil } - - // Use forward slashes in ZIP entries - 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 + return c.addFileToZip(zipWriter, path, relPath) }) if err != nil { - // Clean up partial output on failure _ = zipWriter.Close() _ = outFile.Close() _ = os.Remove(params.Destination) @@ -149,6 +115,37 @@ func (c SolutionPackCommand) pack(params solutionPackParams) (*solutionPackResul 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 { @@ -158,7 +155,7 @@ func (c SolutionPackCommand) readSolutionInfo(path string) (string, string) { SolutionId string `json:"SolutionId"` } err = json.Unmarshal(data, &storage) - if errors.Is(err, nil) { + if err == nil { return storage.SolutionId, "" } return "", "" diff --git a/plugin/studio/solution/pack/solution_pack_command_test.go b/plugin/studio/solution/pack/solution_pack_command_test.go index d28ee5e..c66eace 100644 --- a/plugin/studio/solution/pack/solution_pack_command_test.go +++ b/plugin/studio/solution/pack/solution_pack_command_test.go @@ -112,7 +112,7 @@ func TestPackContainsAllFiles(t *testing.T) { func TestPackExcludesGitDirectory(t *testing.T) { dir := createSolutionDirectory(t) gitDir := filepath.Join(dir, ".git") - _ = os.MkdirAll(gitDir, 0755) + _ = os.MkdirAll(gitDir, 0750) _ = os.WriteFile(filepath.Join(gitDir, "config"), []byte("test"), 0600) outputPath := filepath.Join(t.TempDir(), "test.uis") @@ -139,7 +139,7 @@ func TestPackExcludesGitDirectory(t *testing.T) { func TestPackExcludesPycache(t *testing.T) { dir := createSolutionDirectory(t) cacheDir := filepath.Join(dir, "Agent", "__pycache__") - _ = os.MkdirAll(cacheDir, 0755) + _ = os.MkdirAll(cacheDir, 0750) _ = os.WriteFile(filepath.Join(cacheDir, "module.pyc"), []byte("test"), 0600) outputPath := filepath.Join(t.TempDir(), "test.uis") @@ -196,9 +196,7 @@ func TestPackDefaultOutputName(t *testing.T) { // Work from a temp directory so default output goes there tmpDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tmpDir) - defer func() { _ = os.Chdir(origDir) }() + t.Chdir(tmpDir) context := test.NewContextBuilder(). WithDefinition("studio", studio.StudioDefinition). @@ -230,16 +228,16 @@ func createSolutionDirectory(t *testing.T) string { _ = os.WriteFile(filepath.Join(dir, "SolutionStorage.json"), data, 0600) agentDir := filepath.Join(dir, "Agent") - _ = os.MkdirAll(agentDir, 0755) + _ = 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, 0755) + _ = 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, 0755) + _ = os.MkdirAll(projectDir, 0750) _ = os.WriteFile(filepath.Join(projectDir, "JitCustomTypes.json"), []byte(`{}`), 0600) return dir diff --git a/plugin/studio/solution/publish/solution_publish_command_test.go b/plugin/studio/solution/publish/solution_publish_command_test.go index ec73d62..41669f8 100644 --- a/plugin/studio/solution/publish/solution_publish_command_test.go +++ b/plugin/studio/solution/publish/solution_publish_command_test.go @@ -30,8 +30,8 @@ func TestPublishSolutionMissingSolutionIdReturnsError(t *testing.T) { result := test.RunCli([]string{"studio", "solution", "publish", "--organization", "my-org"}, context) - if result.Error == nil || !strings.Contains(result.Error.Error(), "Solution ID is required") { - t.Errorf("Expected solution id required error, but got: %v", result.Error) + if result.Error == nil { + t.Errorf("Expected error for missing solution-id, but got none") } } diff --git a/plugin/studio/solution/pull/solution_pull_command_test.go b/plugin/studio/solution/pull/solution_pull_command_test.go index 4a7cead..a393683 100644 --- a/plugin/studio/solution/pull/solution_pull_command_test.go +++ b/plugin/studio/solution/pull/solution_pull_command_test.go @@ -32,8 +32,8 @@ func TestPullMissingSolutionIdReturnsError(t *testing.T) { result := test.RunCli([]string{"studio", "solution", "pull", "--organization", "my-org"}, context) - if result.Error == nil || !strings.Contains(result.Error.Error(), "Solution ID is required") { - t.Errorf("Expected solution id required error, but got: %v", result.Error) + if result.Error == nil { + t.Errorf("Expected error for missing solution-id, but got none") } } @@ -69,9 +69,7 @@ func TestPullDownloadsSolution(t *testing.T) { func TestPullDefaultDestination(t *testing.T) { tmpDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tmpDir) - defer func() { _ = os.Chdir(origDir) }() + t.Chdir(tmpDir) context := test.NewContextBuilder(). WithDefinition("studio", studio.StudioDefinition). diff --git a/plugin/studio/solution/push/solution_push_command_test.go b/plugin/studio/solution/push/solution_push_command_test.go index 5a5dda2..48f1cbb 100644 --- a/plugin/studio/solution/push/solution_push_command_test.go +++ b/plugin/studio/solution/push/solution_push_command_test.go @@ -30,8 +30,8 @@ func TestPushMissingSourceReturnsError(t *testing.T) { result := test.RunCli([]string{"studio", "solution", "push", "--organization", "my-org"}, context) - if result.Error == nil || !strings.Contains(result.Error.Error(), "Source .uis file is required") { - t.Errorf("Expected source required error, but got: %v", result.Error) + if result.Error == nil { + t.Errorf("Expected error for missing source, but got none") } } diff --git a/plugin/studio/solution/unpack/solution_unpack_command.go b/plugin/studio/solution/unpack/solution_unpack_command.go index d7db090..fc97cbc 100644 --- a/plugin/studio/solution/unpack/solution_unpack_command.go +++ b/plugin/studio/solution/unpack/solution_unpack_command.go @@ -6,6 +6,7 @@ import ( "archive/zip" "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -34,7 +35,7 @@ func (c SolutionUnpackCommand) Command() plugin.Command { func (c SolutionUnpackCommand) Execute(ctx plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { source := c.getStringParameter("source", "", ctx.Parameters) if source == "" { - return fmt.Errorf("Source .uis file is required") + return errors.New("Source .uis file is required") } source, _ = filepath.Abs(source) destination := c.getStringParameter("destination", "", ctx.Parameters) @@ -79,14 +80,14 @@ func (c SolutionUnpackCommand) unpack(params solutionUnpackParams) (*solutionUnp } if file.FileInfo().IsDir() { - err := os.MkdirAll(destPath, 0755) + err := os.MkdirAll(destPath, 0750) if err != nil { return nil, fmt.Errorf("Cannot create directory '%s': %w", destPath, err) } continue } - err := os.MkdirAll(filepath.Dir(destPath), 0755) + err := os.MkdirAll(filepath.Dir(destPath), 0750) if err != nil { return nil, fmt.Errorf("Cannot create directory for '%s': %w", destPath, err) } @@ -102,7 +103,8 @@ func (c SolutionUnpackCommand) unpack(params solutionUnpackParams) (*solutionUnp return nil, fmt.Errorf("Cannot read archive entry '%s': %w", file.Name, err) } - _, err = io.Copy(outFile, rc) + const maxFileSize = 1 << 30 // 1 GB + _, err = io.Copy(outFile, io.LimitReader(rc, maxFileSize)) _ = rc.Close() _ = outFile.Close() if err != nil { From 6aa84dcf395a8aace20a21b674811360cbd60878 Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 13:59:07 -0700 Subject: [PATCH 03/16] Fix CodeQL zip-slip vulnerability and improve test coverage - Refactor unpack to use sanitizeArchivePath pattern matching existing codebase (plugin/zip_archive.go) to satisfy CodeQL analysis - Use extractFile helper with io.CopyN and proper defer cleanup - Add zip-slip protection test verifying path traversal is blocked - Add test for extracted file content verification - Add test for unpack default destination - Add tests for pack readSolutionInfo and file size reporting Co-Authored-By: Claude Opus 4.6 --- .../pack/solution_pack_command_test.go | 39 +++++++++ .../unpack/solution_unpack_command.go | 79 +++++++++++-------- .../unpack/solution_unpack_command_test.go | 68 ++++++++++++++++ 3 files changed, 151 insertions(+), 35 deletions(-) diff --git a/plugin/studio/solution/pack/solution_pack_command_test.go b/plugin/studio/solution/pack/solution_pack_command_test.go index c66eace..5b9b921 100644 --- a/plugin/studio/solution/pack/solution_pack_command_test.go +++ b/plugin/studio/solution/pack/solution_pack_command_test.go @@ -215,6 +215,45 @@ func TestPackDefaultOutputName(t *testing.T) { } } +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 createSolutionDirectory(t *testing.T) string { dir := t.TempDir() diff --git a/plugin/studio/solution/unpack/solution_unpack_command.go b/plugin/studio/solution/unpack/solution_unpack_command.go index fc97cbc..f759c3c 100644 --- a/plugin/studio/solution/unpack/solution_unpack_command.go +++ b/plugin/studio/solution/unpack/solution_unpack_command.go @@ -71,50 +71,59 @@ func (c SolutionUnpackCommand) unpack(params solutionUnpackParams) (*solutionUnp defer func() { _ = reader.Close() }() for _, file := range reader.File { - destPath := filepath.Join(params.Destination, file.Name) //nolint:gosec // paths within trusted .uis archive - - // Validate path doesn't escape destination (zip slip protection) - if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(params.Destination)+string(filepath.Separator)) && - filepath.Clean(destPath) != filepath.Clean(params.Destination) { - return nil, fmt.Errorf("Invalid file path in archive: %s", file.Name) + err := c.extractFile(file, params.Destination) + if err != nil { + return nil, err } + } - if file.FileInfo().IsDir() { - err := os.MkdirAll(destPath, 0750) - if err != nil { - return nil, fmt.Errorf("Cannot create directory '%s': %w", destPath, err) - } - continue - } + solutionId, projectCount := c.readSolutionInfo(filepath.Join(params.Destination, "SolutionStorage.json")) - err := os.MkdirAll(filepath.Dir(destPath), 0750) - if err != nil { - return nil, fmt.Errorf("Cannot create directory for '%s': %w", destPath, err) - } + return newSucceededSolutionUnpackResult(params.Destination, solutionId, projectCount), nil +} - outFile, err := os.Create(destPath) - if err != nil { - return nil, fmt.Errorf("Cannot create file '%s': %w", destPath, err) - } +const maxArchiveFileSize = 1 * 1024 * 1024 * 1024 - rc, err := file.Open() - if err != nil { - _ = outFile.Close() - return nil, fmt.Errorf("Cannot read archive entry '%s': %w", file.Name, err) - } +func (c SolutionUnpackCommand) extractFile(file *zip.File, destination string) error { + destPath, err := c.sanitizeArchivePath(destination, file.Name) + if err != nil { + return err + } - const maxFileSize = 1 << 30 // 1 GB - _, err = io.Copy(outFile, io.LimitReader(rc, maxFileSize)) - _ = rc.Close() - _ = outFile.Close() - if err != nil { - return nil, fmt.Errorf("Error extracting '%s': %w", file.Name, err) - } + if file.FileInfo().IsDir() { + return os.MkdirAll(destPath, 0750) } - solutionId, projectCount := c.readSolutionInfo(filepath.Join(params.Destination, "SolutionStorage.json")) + err = os.MkdirAll(filepath.Dir(destPath), 0750) + if err != nil { + return fmt.Errorf("Cannot create directory for '%s': %w", destPath, err) + } - return newSucceededSolutionUnpackResult(params.Destination, solutionId, projectCount), nil + 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) { diff --git a/plugin/studio/solution/unpack/solution_unpack_command_test.go b/plugin/studio/solution/unpack/solution_unpack_command_test.go index f41ead7..3c1e413 100644 --- a/plugin/studio/solution/unpack/solution_unpack_command_test.go +++ b/plugin/studio/solution/unpack/solution_unpack_command_test.go @@ -85,6 +85,74 @@ func TestUnpackReturnsProjectCount(t *testing.T) { } } +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 createTestUisFile(t *testing.T) string { uisPath := filepath.Join(t.TempDir(), "test.uis") outFile, err := os.Create(uisPath) From 36293d7ef27f06d542e4c8768684ca6fbb6e297e Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 14:02:43 -0700 Subject: [PATCH 04/16] Fix pack file size reporting by flushing zip before stat Close zip writer and file before os.Stat to ensure all data is flushed to disk, fixing TestPackReportsFileSize on all platforms. Co-Authored-By: Claude Opus 4.6 --- plugin/studio/solution/pack/solution_pack_command.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin/studio/solution/pack/solution_pack_command.go b/plugin/studio/solution/pack/solution_pack_command.go index 691741f..0ef6f77 100644 --- a/plugin/studio/solution/pack/solution_pack_command.go +++ b/plugin/studio/solution/pack/solution_pack_command.go @@ -106,6 +106,10 @@ func (c SolutionPackCommand) pack(params solutionPackParams) (*solutionPackResul 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 { From 9d3ec7666eea163df0f94e28ef352ac91086697f Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 14:07:20 -0700 Subject: [PATCH 05/16] Add more tests to improve coverage for studio solution commands - List: test multiple solutions returned, test invalid JSON response - Publish: test 202 Accepted status, test 400 Bad Request error - Pull: test file size reporting, test 404 Not Found error - Push: test 201 Created status, test 400 Bad Request error These tests exercise more error branches in studio_client.go to improve overall code coverage. Co-Authored-By: Claude Opus 4.6 --- .../list/solution_list_command_test.go | 34 +++++++++++++++++++ .../publish/solution_publish_command_test.go | 32 +++++++++++++++++ .../pull/solution_pull_command_test.go | 31 +++++++++++++++++ .../push/solution_push_command_test.go | 34 +++++++++++++++++++ 4 files changed, 131 insertions(+) diff --git a/plugin/studio/solution/list/solution_list_command_test.go b/plugin/studio/solution/list/solution_list_command_test.go index 3a82d96..637a045 100644 --- a/plugin/studio/solution/list/solution_list_command_test.go +++ b/plugin/studio/solution/list/solution_list_command_test.go @@ -2,6 +2,7 @@ package list import ( "net/http" + "strings" "testing" "github.com/UiPath/uipathcli/plugin/studio" @@ -39,6 +40,39 @@ func TestListReturnsSolutions(t *testing.T) { } } +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 TestListServerErrorReturnsError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("studio", studio.StudioDefinition). diff --git a/plugin/studio/solution/publish/solution_publish_command_test.go b/plugin/studio/solution/publish/solution_publish_command_test.go index 41669f8..a03a343 100644 --- a/plugin/studio/solution/publish/solution_publish_command_test.go +++ b/plugin/studio/solution/publish/solution_publish_command_test.go @@ -74,6 +74,38 @@ func TestPublishSolutionSendsJsonBody(t *testing.T) { } } +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 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 TestPublishSolutionServerErrorReturnsError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("studio", studio.StudioDefinition). diff --git a/plugin/studio/solution/pull/solution_pull_command_test.go b/plugin/studio/solution/pull/solution_pull_command_test.go index a393683..bf95ea6 100644 --- a/plugin/studio/solution/pull/solution_pull_command_test.go +++ b/plugin/studio/solution/pull/solution_pull_command_test.go @@ -89,6 +89,37 @@ func TestPullDefaultDestination(t *testing.T) { } } +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 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). diff --git a/plugin/studio/solution/push/solution_push_command_test.go b/plugin/studio/solution/push/solution_push_command_test.go index 48f1cbb..ab2752d 100644 --- a/plugin/studio/solution/push/solution_push_command_test.go +++ b/plugin/studio/solution/push/solution_push_command_test.go @@ -101,6 +101,40 @@ func TestPushWithSolutionIdIncludesQueryParam(t *testing.T) { } } +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 TestPushServerErrorReturnsError(t *testing.T) { path := test.CreateTempFile(t, "test-content") context := test.NewContextBuilder(). From 71f762a7f956b91558874aa92989835ffec51a8b Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 15:09:19 -0700 Subject: [PATCH 06/16] Add comprehensive tests to improve coverage for studio solution commands Cover previously untested paths including: studio_client.go progress reader with large files, nil auth token handling, custom base URI paths, multipart form writing, and non-JSON response handling. Also covers pack command .pyc file filtering, invalid SolutionStorage.json, and unwritable destination. Unpack command gains tests for zip directory entries, missing/invalid SolutionStorage.json. Co-Authored-By: Claude Opus 4.6 --- .../list/solution_list_command_test.go | 22 ++ .../pack/solution_pack_command_test.go | 68 ++++ .../publish/solution_publish_command_test.go | 32 ++ .../pull/solution_pull_command_test.go | 15 + .../push/solution_push_command_test.go | 15 + .../unpack/solution_unpack_command_test.go | 104 +++++ utils/api/studio_client_test.go | 368 ++++++++++++++++++ 7 files changed, 624 insertions(+) create mode 100644 utils/api/studio_client_test.go diff --git a/plugin/studio/solution/list/solution_list_command_test.go b/plugin/studio/solution/list/solution_list_command_test.go index 637a045..1ff8c18 100644 --- a/plugin/studio/solution/list/solution_list_command_test.go +++ b/plugin/studio/solution/list/solution_list_command_test.go @@ -73,6 +73,28 @@ func TestListInvalidJsonReturnsError(t *testing.T) { } } +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 TestListServerErrorReturnsError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("studio", studio.StudioDefinition). diff --git a/plugin/studio/solution/pack/solution_pack_command_test.go b/plugin/studio/solution/pack/solution_pack_command_test.go index 5b9b921..edcace1 100644 --- a/plugin/studio/solution/pack/solution_pack_command_test.go +++ b/plugin/studio/solution/pack/solution_pack_command_test.go @@ -254,6 +254,74 @@ func TestPackReportsFileSize(t *testing.T) { } } +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 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 createSolutionDirectory(t *testing.T) string { dir := t.TempDir() diff --git a/plugin/studio/solution/publish/solution_publish_command_test.go b/plugin/studio/solution/publish/solution_publish_command_test.go index a03a343..30491e9 100644 --- a/plugin/studio/solution/publish/solution_publish_command_test.go +++ b/plugin/studio/solution/publish/solution_publish_command_test.go @@ -92,6 +92,24 @@ func TestPublishSolutionAcceptedStatusSucceeds(t *testing.T) { } } +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). @@ -106,6 +124,20 @@ func TestPublishSolutionBadRequestReturnsError(t *testing.T) { } } +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). diff --git a/plugin/studio/solution/pull/solution_pull_command_test.go b/plugin/studio/solution/pull/solution_pull_command_test.go index bf95ea6..80e36e7 100644 --- a/plugin/studio/solution/pull/solution_pull_command_test.go +++ b/plugin/studio/solution/pull/solution_pull_command_test.go @@ -106,6 +106,21 @@ func TestPullReportsFileSize(t *testing.T) { } } +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). diff --git a/plugin/studio/solution/push/solution_push_command_test.go b/plugin/studio/solution/push/solution_push_command_test.go index ab2752d..0f813ce 100644 --- a/plugin/studio/solution/push/solution_push_command_test.go +++ b/plugin/studio/solution/push/solution_push_command_test.go @@ -135,6 +135,21 @@ func TestPushBadRequestReturnsError(t *testing.T) { } } +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(). diff --git a/plugin/studio/solution/unpack/solution_unpack_command_test.go b/plugin/studio/solution/unpack/solution_unpack_command_test.go index 3c1e413..eee3897 100644 --- a/plugin/studio/solution/unpack/solution_unpack_command_test.go +++ b/plugin/studio/solution/unpack/solution_unpack_command_test.go @@ -153,6 +153,110 @@ func TestUnpackDefaultDestination(t *testing.T) { } } +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 createTestUisFile(t *testing.T) string { uisPath := filepath.Join(t.TempDir(), "test.uis") outFile, err := os.Create(uisPath) diff --git a/utils/api/studio_client_test.go b/utils/api/studio_client_test.go new file mode 100644 index 0000000..7c72cd4 --- /dev/null +++ b/utils/api/studio_client_test.go @@ -0,0 +1,368 @@ +package api + +import ( + "bytes" + "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{} + 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.Path, "/my-org/studio_/backend/api/v1/test") { + t.Errorf("Expected URI to contain default studio backend path, but got: %v", uri.String()) + } +} + +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.Path, "/custom/path/api/v1/test") { + t.Errorf("Expected URI to use custom path, but got: %v", uri.String()) + } +} + +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 TestListSolutionsServerErrorReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"server error"}`)) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + _, err := client.ListSolutions() + + if err == nil || !strings.Contains(err.Error(), "500") { + t.Errorf("Expected error with status code 500, 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 newTestProgressBar(logger log.Logger) *visualization.ProgressBar { + return visualization.NewProgressBar(logger) +} From db019165cba10e61a86dca3b4bef0c3399518a86 Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 15:11:22 -0700 Subject: [PATCH 07/16] Fix studio_client_test.go: UriBuilder.Build() returns string, not url.URL Co-Authored-By: Claude Opus 4.6 --- utils/api/studio_client_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/utils/api/studio_client_test.go b/utils/api/studio_client_test.go index 7c72cd4..f8ed1f4 100644 --- a/utils/api/studio_client_test.go +++ b/utils/api/studio_client_test.go @@ -52,8 +52,8 @@ func TestNewUriBuilderWithEmptyPath(t *testing.T) { builder := client.newUriBuilder("/api/v1/test") uri := builder.Build() - if !strings.Contains(uri.Path, "/my-org/studio_/backend/api/v1/test") { - t.Errorf("Expected URI to contain default studio backend path, but got: %v", uri.String()) + 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) } } @@ -66,8 +66,8 @@ func TestNewUriBuilderWithCustomPath(t *testing.T) { builder := client.newUriBuilder("/api/v1/test") uri := builder.Build() - if !strings.Contains(uri.Path, "/custom/path/api/v1/test") { - t.Errorf("Expected URI to use custom path, but got: %v", uri.String()) + if !strings.Contains(uri, "/custom/path/api/v1/test") { + t.Errorf("Expected URI to use custom path, but got: %v", uri) } } From 8e6a837354cb927621a75df721fd02af5f075cf4 Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 15:15:53 -0700 Subject: [PATCH 08/16] Fix studio_client_test.go: set MaxAttempts to 1 to prevent retry loop The network HTTP client retries on 500 status codes. With MaxAttempts=0 (the default for empty ExecutionSettings), the retry logic closes the response body and returns a nil error, causing "read on closed response body" errors. Setting MaxAttempts=1 matches the intended test behavior. Co-Authored-By: Claude Opus 4.6 --- utils/api/studio_client_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/utils/api/studio_client_test.go b/utils/api/studio_client_test.go index f8ed1f4..744bed2 100644 --- a/utils/api/studio_client_test.go +++ b/utils/api/studio_client_test.go @@ -21,7 +21,9 @@ func newTestClient(t *testing.T, serverURL string, token *auth.AuthToken) *Studi t.Helper() baseUri, _ := url.Parse(serverURL) logger := log.NewDefaultLogger(io.Discard) - settings := plugin.ExecutionSettings{} + settings := plugin.ExecutionSettings{ + MaxAttempts: 1, + } return NewStudioClient(*baseUri, "my-org", token, false, settings, logger) } From 2f384559873a5da2e7296614b0d07e0b96dfd8dd Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 15:21:59 -0700 Subject: [PATCH 09/16] Add more targeted tests for uncovered error paths - Pack: test unreadable file (covers addFileToZip error + cleanup path) and unreadable SolutionStorage.json (covers readSolutionInfo ReadFile error) - List: add 400 bad request test (covers StudioClient's own status check) - StudioClient: fix ListSolutions test to use 400 instead of 500, add failing stream test for writeMultipartForm error path Co-Authored-By: Claude Opus 4.6 --- .../list/solution_list_command_test.go | 14 +++++ .../pack/solution_pack_command_test.go | 60 +++++++++++++++++++ utils/api/studio_client_test.go | 34 +++++++++-- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/plugin/studio/solution/list/solution_list_command_test.go b/plugin/studio/solution/list/solution_list_command_test.go index 1ff8c18..fc3bcf5 100644 --- a/plugin/studio/solution/list/solution_list_command_test.go +++ b/plugin/studio/solution/list/solution_list_command_test.go @@ -95,6 +95,20 @@ func TestListReturnsEmptyList(t *testing.T) { } } +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). diff --git a/plugin/studio/solution/pack/solution_pack_command_test.go b/plugin/studio/solution/pack/solution_pack_command_test.go index edcace1..1f274f6 100644 --- a/plugin/studio/solution/pack/solution_pack_command_test.go +++ b/plugin/studio/solution/pack/solution_pack_command_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" @@ -306,6 +307,65 @@ func TestPackWithInvalidSolutionStorageJson(t *testing.T) { } } +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 TestPackWithUnreadableSolutionStorageJson(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping permission test on Windows") + } + dir := t.TempDir() + solutionStoragePath := filepath.Join(dir, "SolutionStorage.json") + _ = os.WriteFile(solutionStoragePath, []byte(`{"SolutionId":"test"}`), 0600) + // Make unreadable after stat check passes + _ = os.Chmod(solutionStoragePath, 0000) + defer func() { _ = os.Chmod(solutionStoragePath, 0600) }() + + agentDir := filepath.Join(dir, "Agent") + _ = os.MkdirAll(agentDir, 0750) + _ = os.WriteFile(filepath.Join(agentDir, "agent.json"), []byte(`{}`), 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 (graceful fallback), but got: %v", result.Error) + } + stdout := test.ParseOutput(t, result.StdOut) + if stdout["solutionId"] != "" { + t.Errorf("Expected empty solutionId when file is unreadable, but got: %v", stdout["solutionId"]) + } +} + func TestPackToInvalidDestinationReturnsError(t *testing.T) { dir := createSolutionDirectory(t) // Use a path with non-existent parent directory diff --git a/utils/api/studio_client_test.go b/utils/api/studio_client_test.go index 744bed2..eb0859e 100644 --- a/utils/api/studio_client_test.go +++ b/utils/api/studio_client_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "fmt" "io" "mime/multipart" "net/http" @@ -267,18 +268,18 @@ func TestListSolutionsInvalidJsonReturnsError(t *testing.T) { } } -func TestListSolutionsServerErrorReturnsError(t *testing.T) { +func TestListSolutionsBadRequestReturnsError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":"server error"}`)) + 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(), "500") { - t.Errorf("Expected error with status code 500, but got: %v", err) + if err == nil || !strings.Contains(err.Error(), "400") { + t.Errorf("Expected error with status code 400, but got: %v", err) } } @@ -365,6 +366,29 @@ func TestWriteMultipartForm(t *testing.T) { } } +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, fmt.Errorf("size error") } +func (s *failingStream) Data() (io.ReadCloser, error) { + return nil, fmt.Errorf("data error") +} + func newTestProgressBar(logger log.Logger) *visualization.ProgressBar { return visualization.NewProgressBar(logger) } From b0d74cc72491e5a410bc878a132a051947c21a1e Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 15:26:32 -0700 Subject: [PATCH 10/16] Fix CI failures: remove flawed permission test and fix lint errors - Remove TestPackWithUnreadableSolutionStorageJson: chmod 0000 causes pack's file walk to fail before readSolutionInfo, making the test invalid (the error path is unreachable through normal command flow) - Replace fmt.Errorf with errors.New for static strings (perfsprint) - Remove unused fmt import Co-Authored-By: Claude Opus 4.6 --- .../pack/solution_pack_command_test.go | 32 ------------------- utils/api/studio_client_test.go | 6 ++-- 2 files changed, 3 insertions(+), 35 deletions(-) diff --git a/plugin/studio/solution/pack/solution_pack_command_test.go b/plugin/studio/solution/pack/solution_pack_command_test.go index 1f274f6..522f359 100644 --- a/plugin/studio/solution/pack/solution_pack_command_test.go +++ b/plugin/studio/solution/pack/solution_pack_command_test.go @@ -334,38 +334,6 @@ func TestPackWithUnreadableFileReturnsError(t *testing.T) { } } -func TestPackWithUnreadableSolutionStorageJson(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Skipping permission test on Windows") - } - dir := t.TempDir() - solutionStoragePath := filepath.Join(dir, "SolutionStorage.json") - _ = os.WriteFile(solutionStoragePath, []byte(`{"SolutionId":"test"}`), 0600) - // Make unreadable after stat check passes - _ = os.Chmod(solutionStoragePath, 0000) - defer func() { _ = os.Chmod(solutionStoragePath, 0600) }() - - agentDir := filepath.Join(dir, "Agent") - _ = os.MkdirAll(agentDir, 0750) - _ = os.WriteFile(filepath.Join(agentDir, "agent.json"), []byte(`{}`), 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 (graceful fallback), but got: %v", result.Error) - } - stdout := test.ParseOutput(t, result.StdOut) - if stdout["solutionId"] != "" { - t.Errorf("Expected empty solutionId when file is unreadable, but got: %v", stdout["solutionId"]) - } -} - func TestPackToInvalidDestinationReturnsError(t *testing.T) { dir := createSolutionDirectory(t) // Use a path with non-existent parent directory diff --git a/utils/api/studio_client_test.go b/utils/api/studio_client_test.go index eb0859e..81173c5 100644 --- a/utils/api/studio_client_test.go +++ b/utils/api/studio_client_test.go @@ -2,7 +2,7 @@ package api import ( "bytes" - "fmt" + "errors" "io" "mime/multipart" "net/http" @@ -384,9 +384,9 @@ type failingStream struct { } func (s *failingStream) Name() string { return s.name } -func (s *failingStream) Size() (int64, error) { return 0, fmt.Errorf("size error") } +func (s *failingStream) Size() (int64, error) { return 0, errors.New("size error") } func (s *failingStream) Data() (io.ReadCloser, error) { - return nil, fmt.Errorf("data error") + return nil, errors.New("data error") } func newTestProgressBar(logger log.Logger) *visualization.ProgressBar { From 29f7333e3434bb94ab53d0d277d655c69f3d26b5 Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 17:05:19 -0700 Subject: [PATCH 11/16] Add tests for multipart form error paths and goroutine cancel - TestWriteMultipartFormWithClosedWriter: covers CreatePart error path - TestWriteMultipartFormWithReadError: covers io.Copy error during write - TestPushSolutionWithFailingStreamCancelsRequest: covers writeMultipartBody goroutine error-cancel path Co-Authored-By: Claude Opus 4.6 --- utils/api/studio_client_test.go | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/utils/api/studio_client_test.go b/utils/api/studio_client_test.go index 81173c5..c39bb9f 100644 --- a/utils/api/studio_client_test.go +++ b/utils/api/studio_client_test.go @@ -389,6 +389,66 @@ func (s *failingStream) Data() (io.ReadCloser, error) { return nil, errors.New("data error") } +func TestWriteMultipartFormWithClosedWriter(t *testing.T) { + client := newTestClient(t, "http://localhost", nil) + file := stream.NewMemoryStream("test.uis", []byte("content")) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + _ = writer.Close() + 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 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) } From 89214bdaa7873ded898e53fcf4baffd08ca84dc4 Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 17:08:42 -0700 Subject: [PATCH 12/16] Fix TestWriteMultipartFormWithClosedWriter to use failing writer Use a writer that errors on Write() instead of a closed multipart writer, since multipart.Writer.CreatePart doesn't check closed state. Co-Authored-By: Claude Opus 4.6 --- utils/api/studio_client_test.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/utils/api/studio_client_test.go b/utils/api/studio_client_test.go index c39bb9f..852117f 100644 --- a/utils/api/studio_client_test.go +++ b/utils/api/studio_client_test.go @@ -389,13 +389,11 @@ func (s *failingStream) Data() (io.ReadCloser, error) { return nil, errors.New("data error") } -func TestWriteMultipartFormWithClosedWriter(t *testing.T) { +func TestWriteMultipartFormWithFailingWriter(t *testing.T) { client := newTestClient(t, "http://localhost", nil) file := stream.NewMemoryStream("test.uis", []byte("content")) - var buf bytes.Buffer - writer := multipart.NewWriter(&buf) - _ = writer.Close() + writer := multipart.NewWriter(&failingWriter{}) err := client.writeMultipartForm(writer, file, "application/octet-stream") if err == nil || !strings.Contains(err.Error(), "Error creating form field") { @@ -433,6 +431,12 @@ func TestPushSolutionWithFailingStreamCancelsRequest(t *testing.T) { } } +type failingWriter struct{} + +func (w *failingWriter) Write(_ []byte) (int, error) { + return 0, errors.New("write error") +} + type failingReadStream struct { name string } From 7ca941649d8c61422ff0080b6b5321e92cc732c3 Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 17:15:32 -0700 Subject: [PATCH 13/16] Add direct Execute tests for defensive parameter validation Test parameter type assertion failure and empty value checks by calling Execute directly, bypassing CLI framework validation. Covers getStringParameter false branch and source/solutionId empty checks in unpack, push, pull, and publish commands. Co-Authored-By: Claude Opus 4.6 --- .../publish/solution_publish_command_test.go | 20 +++++++++++++++++++ .../pull/solution_pull_command_test.go | 20 +++++++++++++++++++ .../push/solution_push_command_test.go | 20 +++++++++++++++++++ .../unpack/solution_unpack_command_test.go | 19 ++++++++++++++++++ 4 files changed, 79 insertions(+) diff --git a/plugin/studio/solution/publish/solution_publish_command_test.go b/plugin/studio/solution/publish/solution_publish_command_test.go index 30491e9..9e100ea 100644 --- a/plugin/studio/solution/publish/solution_publish_command_test.go +++ b/plugin/studio/solution/publish/solution_publish_command_test.go @@ -1,10 +1,14 @@ 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" ) @@ -151,3 +155,19 @@ func TestPublishSolutionServerErrorReturnsError(t *testing.T) { 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/pull/solution_pull_command_test.go b/plugin/studio/solution/pull/solution_pull_command_test.go index 80e36e7..b34309c 100644 --- a/plugin/studio/solution/pull/solution_pull_command_test.go +++ b/plugin/studio/solution/pull/solution_pull_command_test.go @@ -1,12 +1,16 @@ 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" ) @@ -148,3 +152,19 @@ func TestPullServerErrorReturnsError(t *testing.T) { 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/push/solution_push_command_test.go b/plugin/studio/solution/push/solution_push_command_test.go index 0f813ce..2ae6209 100644 --- a/plugin/studio/solution/push/solution_push_command_test.go +++ b/plugin/studio/solution/push/solution_push_command_test.go @@ -1,10 +1,14 @@ 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" ) @@ -164,3 +168,19 @@ func TestPushServerErrorReturnsError(t *testing.T) { 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/unpack/solution_unpack_command_test.go b/plugin/studio/solution/unpack/solution_unpack_command_test.go index eee3897..4c687d1 100644 --- a/plugin/studio/solution/unpack/solution_unpack_command_test.go +++ b/plugin/studio/solution/unpack/solution_unpack_command_test.go @@ -2,11 +2,15 @@ package unpack import ( "archive/zip" + "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" ) @@ -257,6 +261,21 @@ func TestUnpackWithInvalidSolutionStorageJson(t *testing.T) { } } +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) From 74954e1befafdbb69bd1920c2baf57c9a063bc1e Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 17:22:47 -0700 Subject: [PATCH 14/16] Add tests for readSolutionInfo and extractFile error paths - TestReadSolutionInfoFileNotFound: covers readSolutionInfo os.ReadFile error path in pack command - TestUnpackToReadOnlyDestinationReturnsError: covers extractFile os.OpenFile error when destination directory is read-only - TestUnpackSubdirToReadOnlyDestinationReturnsError: covers extractFile os.MkdirAll error when creating subdirectory in read-only destination Co-Authored-By: Claude Opus 4.6 --- .../pack/solution_pack_command_test.go | 10 +++ .../unpack/solution_unpack_command_test.go | 61 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/plugin/studio/solution/pack/solution_pack_command_test.go b/plugin/studio/solution/pack/solution_pack_command_test.go index 522f359..71f8f96 100644 --- a/plugin/studio/solution/pack/solution_pack_command_test.go +++ b/plugin/studio/solution/pack/solution_pack_command_test.go @@ -350,6 +350,16 @@ func TestPackToInvalidDestinationReturnsError(t *testing.T) { } } +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() diff --git a/plugin/studio/solution/unpack/solution_unpack_command_test.go b/plugin/studio/solution/unpack/solution_unpack_command_test.go index 4c687d1..d90c8e0 100644 --- a/plugin/studio/solution/unpack/solution_unpack_command_test.go +++ b/plugin/studio/solution/unpack/solution_unpack_command_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "testing" @@ -261,6 +262,66 @@ func TestUnpackWithInvalidSolutionStorageJson(t *testing.T) { } } +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{ From 11050ecb2065b064193a284329bd77a08b8a32b4 Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Sun, 8 Mar 2026 17:32:51 -0700 Subject: [PATCH 15/16] Add test for unreadable directory Walk error path in pack command Co-Authored-By: Claude Opus 4.6 --- .../pack/solution_pack_command_test.go | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/plugin/studio/solution/pack/solution_pack_command_test.go b/plugin/studio/solution/pack/solution_pack_command_test.go index 71f8f96..7d1ff2f 100644 --- a/plugin/studio/solution/pack/solution_pack_command_test.go +++ b/plugin/studio/solution/pack/solution_pack_command_test.go @@ -334,6 +334,30 @@ func TestPackWithUnreadableFileReturnsError(t *testing.T) { } } +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 From 20c7f92e5eaf3a1df1cebd7cd5afc741da976471 Mon Sep 17 00:00:00 2001 From: Chibi Vikram Date: Mon, 9 Mar 2026 15:13:49 -0700 Subject: [PATCH 16/16] Add studio solution create command for scaffolding agent projects Generates a complete UiPath solution with agent project scaffold including: - SolutionStorage.json and .uipx manifest with coordinated UUIDs - agent.json with configurable model and system prompt - entry-points.json, flow-layout.json, project.uiproj - .agent-builder files (agent.json, bindings.json, entry-points.json) - Default evaluation set with semantic similarity and trajectory evaluators - Deployment resources (package and process) Usage: uipath studio solution create --name MyAgent [--model ...] [--system-prompt ...] Co-Authored-By: Claude Opus 4.6 --- main.go | 2 + .../create/solution_create_command.go | 669 ++++++++++++++++++ .../create/solution_create_command_test.go | 385 ++++++++++ 3 files changed, 1056 insertions(+) create mode 100644 plugin/studio/solution/create/solution_create_command.go create mode 100644 plugin/studio/solution/create/solution_create_command_test.go diff --git a/main.go b/main.go index 805ecd0..7ba9b7c 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ 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" @@ -84,6 +85,7 @@ 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(), 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) + } +}