diff --git a/USAGE.md b/USAGE.md index 31f38bb1..1685ee02 100644 --- a/USAGE.md +++ b/USAGE.md @@ -756,6 +756,14 @@ tw managed-identities add -o my-org -n hpc1 -p slurm -H login.hpc.example.com tw managed-identities credentials -o my-org -n hpc1 add -l my_linux_user -k ~/.ssh/id_rsa ``` +## Agents + +Run `tw agents -h` to view the supported operations: `list`, `view`, `add`, `update`, `delete`, `enable`, and `disable`. Agents must be enabled for the organization. + +```bash +tw agents add -w my-org/my-workspace -n triage --instructions-file triage.md +``` + ## Audit logs Run `tw audit-logs -h` to view the supported operations: `list`, `view`, and `export`. These commands require a root user. @@ -773,11 +781,11 @@ tw audit-logs export --after 2026-09-01T00:00:00Z --before 2026-09-30T23:59:59Z - AWS Cloud: `--log-group`. Google Cloud: `--project-id`. - HPC platforms: `--propagate-head-job-options`. - Seqera Compute: `--data-retention-policy`. -- `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--tower-config`. +- `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--tower-config`. List the Nextflow versions you can launch with `tw info versions`. - `tw runs star|unstar`, `tw runs view log [--next ]`, and `tw runs delete -i ,`. - `tw pipelines schema`: show a pipeline's parameter schema. - `tw actions add cron|bucket`. -- `tw studios extend|rename-checkpoint`. `--mount-data-uris` also accepts a folder inside a data link. +- `tw studios star|unstar|extend|rename-checkpoint`. `--mount-data-uris` also accepts a folder inside a data link. - `tw data-links delete-content|download-script`. - `tw datasets add --url`, `tw datasets update --url`, and `tw datasets disable-version`. diff --git a/conf/reflect-config.json b/conf/reflect-config.json index 0bc736cb..a253af03 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -166,6 +166,12 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.AgentsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.AuditLogsCmd", "queryAllDeclaredMethods":true, @@ -409,6 +415,81 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.agents.AbstractAgentsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"githubAppCredentialsId"}, {"name":"instructions"}, {"name":"name"}, {"name":"serviceAccountId"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AgentInstructionsOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"file"}, {"name":"text"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AgentRefOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"agent"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AgentRefOptions$AgentRef", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.DisableCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.EnableCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"filter"}, {"name":"paginationOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"githubAppCredentialsId"}, {"name":"instructions"}, {"name":"newName"}, {"name":"ref"}, {"name":"serviceAccountId"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.auditlogs.AuditLogsFilterOptions", "queryAllDeclaredMethods":true, @@ -1485,6 +1566,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.info.VersionsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"component"}, {"name":"fusion"}, {"name":"nextflow"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.labels.AbstractLabelsCmd", "allDeclaredFields":true, @@ -2305,6 +2393,13 @@ "fields":[{"name":"checkpointId"}, {"name":"newName"}, {"name":"studioRefOptions"}, {"name":"workspace"}], "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.StarCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.StartCmd", "allDeclaredFields":true, @@ -2372,6 +2467,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.UnstarCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.UpdateCmd", "allDeclaredFields":true, @@ -2555,6 +2657,19 @@ "allDeclaredConstructors":true, "methods":[{"name":"","parameterTypes":[] }, {"name":"setMessage","parameterTypes":["java.lang.String"] }] }, +{ + "name":"io.seqera.tower.cli.responses.ComponentVersions", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.ComponentVersions$ComponentVersion", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"isDefault","parameterTypes":[] }, {"name":"version","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.responses.CredentialsAdded", "allDeclaredFields":true, @@ -2646,6 +2761,36 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.auditlogs.AuditLogView", "allDeclaredFields":true, @@ -3363,6 +3508,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.studios.StudioStarred", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.studios.StudioStartSubmitted", "allDeclaredFields":true, diff --git a/src/main/java/io/seqera/tower/cli/Tower.java b/src/main/java/io/seqera/tower/cli/Tower.java index 74a92294..b0e4ca33 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -23,6 +23,7 @@ import io.seqera.tower.cli.commands.AbstractCmd; import io.seqera.tower.cli.commands.AccessTokensCmd; import io.seqera.tower.cli.commands.ActionsCmd; +import io.seqera.tower.cli.commands.AgentsCmd; import io.seqera.tower.cli.commands.AuditLogsCmd; import io.seqera.tower.cli.commands.CollaboratorsCmd; import io.seqera.tower.cli.commands.ComputeEnvsCmd; @@ -63,6 +64,7 @@ subcommands = { AccessTokensCmd.class, ActionsCmd.class, + AgentsCmd.class, AuditLogsCmd.class, CollaboratorsCmd.class, ComputeEnvsCmd.class, diff --git a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java index cf52ab0e..d013e389 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -20,6 +20,7 @@ import io.seqera.tower.ApiException; import io.seqera.tower.api.ActionsApi; import io.seqera.tower.api.AdminApi; +import io.seqera.tower.api.AgentsApi; import io.seqera.tower.api.AvatarsApi; import io.seqera.tower.api.ComputeEnvsApi; import io.seqera.tower.api.CredentialsApi; @@ -29,6 +30,7 @@ import io.seqera.tower.api.IdentitiesApi; import io.seqera.tower.api.LabelsApi; import io.seqera.tower.api.LaunchApi; +import io.seqera.tower.api.NextflowApi; import io.seqera.tower.api.OrgsApi; import io.seqera.tower.api.PipelineSchemasApi; import io.seqera.tower.api.PipelineSecretsApi; @@ -130,6 +132,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private IdentitiesApi identitiesApi; private LabelsApi labelsApi; private LaunchApi launchApi; + private NextflowApi nextflowApi; private OrgsApi orgsApi; private PipelinesApi pipelinesApi; private PipelineSchemasApi pipelineSchemasApi; @@ -212,6 +215,10 @@ protected AdminApi adminApi() throws ApiException { return new AdminApi(apiClient()); } + protected AgentsApi agentsApi() throws ApiException { + return new AgentsApi(apiClient()); + } + protected AvatarsApi avatarsApi() throws ApiException { return avatarsApi == null ? new AvatarsApi(apiClient()) : avatarsApi; } @@ -248,6 +255,10 @@ protected LaunchApi launchApi() throws ApiException { return launchApi == null ? new LaunchApi(apiClient()) : launchApi; } + protected NextflowApi nextflowApi() throws ApiException { + return nextflowApi == null ? new NextflowApi(apiClient()) : nextflowApi; + } + protected OrgsApi orgsApi() throws ApiException { return orgsApi == null ? new OrgsApi(apiClient()) : orgsApi; } diff --git a/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java b/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java new file mode 100644 index 00000000..67e8f3fb --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java @@ -0,0 +1,43 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands; + +import io.seqera.tower.cli.commands.agents.AddCmd; +import io.seqera.tower.cli.commands.agents.DeleteCmd; +import io.seqera.tower.cli.commands.agents.DisableCmd; +import io.seqera.tower.cli.commands.agents.EnableCmd; +import io.seqera.tower.cli.commands.agents.ListCmd; +import io.seqera.tower.cli.commands.agents.UpdateCmd; +import io.seqera.tower.cli.commands.agents.ViewCmd; +import picocli.CommandLine.Command; + +@Command( + name = "agents", + description = "Manage AI agents. Agents must be enabled for the workspace organization.", + subcommands = { + ListCmd.class, + ViewCmd.class, + AddCmd.class, + UpdateCmd.class, + DeleteCmd.class, + EnableCmd.class, + DisableCmd.class, + } +) +public class AgentsCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java b/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java index 5b5ab3bb..fece7985 100644 --- a/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java @@ -17,6 +17,7 @@ package io.seqera.tower.cli.commands; import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.info.VersionsCmd; import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.InfoResponse; import io.seqera.tower.cli.responses.Response; @@ -32,7 +33,10 @@ @CommandLine.Command( name = "info", - description = "Show system info and health status" + description = "Show system info and health status", + subcommands = { + VersionsCmd.class, + } ) public class InfoCmd extends AbstractRootCmd { @Override diff --git a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java index 345d7d48..8cbb28b0 100644 --- a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java @@ -21,6 +21,8 @@ import io.seqera.tower.cli.commands.studios.DeleteCmd; import io.seqera.tower.cli.commands.studios.ExtendCmd; import io.seqera.tower.cli.commands.studios.RenameCheckpointCmd; +import io.seqera.tower.cli.commands.studios.StarCmd; +import io.seqera.tower.cli.commands.studios.UnstarCmd; import io.seqera.tower.cli.commands.studios.ListCmd; import io.seqera.tower.cli.commands.studios.AddAsNewCmd; import io.seqera.tower.cli.commands.studios.StartCmd; @@ -45,6 +47,8 @@ StopCmd.class, UpdateCmd.class, DeleteCmd.class, + StarCmd.class, + UnstarCmd.class, ExtendCmd.class, } ) diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AbstractAgentsCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/AbstractAgentsCmd.java new file mode 100644 index 00000000..9b536d4e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AbstractAgentsCmd.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; + +@Command +public abstract class AbstractAgentsCmd extends AbstractApiCmd { + + // The API caps agent pages at 100 entries + private static final int MAX_PAGE_SIZE = 100; + + protected AgentDbDto fetchAgent(AgentRefOptions.AgentRef ref, Long wspId) throws ApiException { + String agentId = ref.id != null ? ref.id : agentByName(ref.name, wspId).getId(); + return agentsApi().describeAgent(agentId, wspId).getAgent(); + } + + private AgentDbDto agentByName(String name, Long wspId) throws ApiException { + for (AgentDbDto agent : agentsApi().listAgents(wspId, name, MAX_PAGE_SIZE, 0).getAgents()) { + if (name.equals(agent.getName())) { + return agent; + } + } + throw new TowerException(String.format("Unknown agent '%s' at %s workspace", name, workspaceRef(wspId))); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java new file mode 100644 index 00000000..b96d4450 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java @@ -0,0 +1,62 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentAdded; +import io.seqera.tower.model.AgentDbDto; +import io.seqera.tower.model.CreateAgentRequest; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +import java.io.IOException; + +@Command( + name = "add", + description = "Add an agent" +) +public class AddCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Option(names = {"-n", "--name"}, description = "Agent name. Must be unique per workspace. Names consist of alphanumeric, hyphen, and underscore characters.", required = true) + public String name; + + @Option(names = {"-d", "--description"}, description = "Agent description (max 120 characters).") + public String description; + + @ArgGroup(multiplicity = "1") + public AgentInstructionsOptions instructions; + + @Override + protected Response exec() throws ApiException, IOException { + Long wspId = workspaceId(workspace.workspace); + CreateAgentRequest request = new CreateAgentRequest() + .name(name) + .description(description) + .agentInstructions(instructions.read()); + + AgentDbDto agent = agentsApi().createAgent(request, wspId).getAgent(); + return new AgentAdded(workspaceRef(wspId), agent); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AgentInstructionsOptions.java b/src/main/java/io/seqera/tower/cli/commands/agents/AgentInstructionsOptions.java new file mode 100644 index 00000000..6fe195dd --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AgentInstructionsOptions.java @@ -0,0 +1,38 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.agents; + +import picocli.CommandLine.Option; + +import java.io.IOException; +import java.nio.file.Path; + +import static io.seqera.tower.cli.utils.FilesHelper.readStringOrStdin; + +public class AgentInstructionsOptions { + + @Option(names = {"--instructions"}, description = "Agent instructions: the role, behavior, and constraints for the agent.") + public String text; + + @Option(names = {"--instructions-file"}, description = "File containing the agent instructions. Use '-' to read from stdin.") + public Path file; + + public String read() throws IOException { + return text != null ? text : readStringOrStdin(file); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AgentRefOptions.java b/src/main/java/io/seqera/tower/cli/commands/agents/AgentRefOptions.java new file mode 100644 index 00000000..9e3db312 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AgentRefOptions.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.agents; + +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +public class AgentRefOptions { + + @ArgGroup(multiplicity = "1") + public AgentRef agent; + + public static class AgentRef { + + @Option(names = {"-i", "--id"}, description = "Agent identifier") + public String id; + + @Option(names = {"-n", "--name"}, description = "Agent name") + public String name; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/DeleteCmd.java new file mode 100644 index 00000000..10795eee --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/DeleteCmd.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentDeleted; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "delete", + description = "Delete an agent" +) +public class DeleteCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + agentsApi().deleteAgent(agent.getId(), wspId); + return new AgentDeleted(workspaceRef(wspId), agent.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/DisableCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/DisableCmd.java new file mode 100644 index 00000000..20c1a1be --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/DisableCmd.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentUpdated; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "disable", + description = "Disable an active agent. A disabled agent cannot be launched." +) +public class DisableCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + agentsApi().disableAgent(agent.getId(), wspId); + return new AgentUpdated(workspaceRef(wspId), agent.getName(), "disabled"); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/EnableCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/EnableCmd.java new file mode 100644 index 00000000..4c96068c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/EnableCmd.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentUpdated; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "enable", + description = "Enable an inactive agent" +) +public class EnableCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + agentsApi().enableAgent(agent.getId(), wspId); + return new AgentUpdated(workspaceRef(wspId), agent.getName(), "enabled"); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/ListCmd.java new file mode 100644 index 00000000..f8c7416a --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/ListCmd.java @@ -0,0 +1,56 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentsList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.ListAgentsResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "list", + description = "List agents" +) +public class ListCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Option(names = {"-f", "--filter"}, description = "Filter agents by name") + public String filter; + + @Mixin + PaginationOptions paginationOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + ListAgentsResponse response = agentsApi().listAgents(wspId, filter, max, offset); + + return new AgentsList(workspaceRef(wspId), response.getAgents(), PaginationInfo.from(paginationOptions, response.getTotalSize())); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java new file mode 100644 index 00000000..ad46ec7f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java @@ -0,0 +1,71 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentUpdated; +import io.seqera.tower.model.AgentDbDto; +import io.seqera.tower.model.UpdateAgentRequest; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +import java.io.IOException; + +import static io.seqera.tower.cli.utils.ModelHelper.coalesce; + +@Command( + name = "update", + description = "Update an agent" +) +public class UpdateCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Option(names = {"--new-name"}, description = "New agent name. Names consist of alphanumeric, hyphen, and underscore characters.") + public String newName; + + @Option(names = {"-d", "--description"}, description = "Agent description (max 120 characters).") + public String description; + + @ArgGroup + public AgentInstructionsOptions instructions; + + @Override + protected Response exec() throws ApiException, IOException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + + // The API replaces the whole agent, so unchanged fields are sent with their current value + UpdateAgentRequest request = new UpdateAgentRequest() + .name(coalesce(newName, agent.getName())) + .description(coalesce(description, agent.getDescription())) + .agentInstructions(instructions != null ? instructions.read() : agent.getAgentInstructions()) + .agentInstructionsTemplateId(agent.getAgentInstructionsTemplateId()); + + AgentDbDto updated = agentsApi().updateAgent(agent.getId(), request, wspId).getAgent(); + return new AgentUpdated(workspaceRef(wspId), updated.getName(), "updated"); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/ViewCmd.java new file mode 100644 index 00000000..b1605e6e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/ViewCmd.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "view", + description = "View agent details" +) +public class ViewCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + return new AgentView(workspaceRef(wspId), fetchAgent(ref.agent, wspId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java b/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java new file mode 100644 index 00000000..a0778ccd --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.info; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.responses.ComponentVersions; +import io.seqera.tower.cli.responses.ComponentVersions.ComponentVersion; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.NextflowVersion; +import picocli.CommandLine.Command; + +import java.util.List; +import java.util.Objects; + +@Command( + name = "versions", + description = "List the Nextflow versions available to launch with" +) +public class VersionsCmd extends AbstractApiCmd { + + private static final String NEXTFLOW = "nextflow"; + + @Override + protected Response exec() throws ApiException { + List versions = Objects.requireNonNullElse(nextflowApi().nextflowVersions().getNextflowVersions(), List.of()).stream() + .map(v -> new ComponentVersion(v.getVersion(), Boolean.TRUE.equals(v.getDefault()))) + .toList(); + return new ComponentVersions(NEXTFLOW, versions); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java index 8d114c9d..dc2c78c2 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java @@ -40,7 +40,7 @@ public class NullableLaunchOptions { @Option(names = {"--syntax-parser"}, description = "Nextflow language syntax parser version: 'v1' (legacy) or 'v2'. Takes precedence over the value stored in the launch configuration.") public SyntaxParserEnum syntaxParser; - @Option(names = {"--nextflow-version"}, description = "Nextflow version to run the workflow with. Must exist in the Platform version catalog and meet the minimum required by the compute environment. Takes precedence over the value stored in the launch configuration.") + @Option(names = {"--nextflow-version"}, description = "Nextflow version to run the workflow with. Must exist in the Platform version catalog and meet the minimum required by the compute environment (see 'tw info versions'). Takes precedence over the value stored in the launch configuration.") public String nextflowVersion; @Option(names = {"--output-dir"}, description = "Per-run output directory, passed to Nextflow as '-output-dir'. Requires Nextflow 24.10.0 or later and the workflow outputs syntax. Takes precedence over the value stored in the launch configuration.") diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/StarCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/StarCmd.java new file mode 100644 index 00000000..079871b5 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/StarCmd.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.studios; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.studios.StudioStarred; +import picocli.CommandLine; + +@CommandLine.Command( + name = "star", + description = "Add a studio to your starred studios." +) +public class StarCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + studiosApi().createDataStudioStar(sessionId, wspId); + + return new StudioStarred(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), true); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/UnstarCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/UnstarCmd.java new file mode 100644 index 00000000..b47d2dc0 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/UnstarCmd.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.studios; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.studios.StudioStarred; +import picocli.CommandLine; + +@CommandLine.Command( + name = "unstar", + description = "Remove a studio from your starred studios." +) +public class UnstarCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + studiosApi().deleteDataStudioStar(sessionId, wspId); + + return new StudioStarred(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), false); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/ComponentVersions.java b/src/main/java/io/seqera/tower/cli/responses/ComponentVersions.java new file mode 100644 index 00000000..4c27c8af --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/ComponentVersions.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.responses; + +import io.seqera.tower.cli.utils.TableList; + +import java.io.PrintWriter; +import java.util.List; + +public class ComponentVersions extends Response { + + public record ComponentVersion(String version, boolean isDefault) { + } + + public final String component; + public final List versions; + + public ComponentVersions(String component, List versions) { + this.component = component; + this.versions = versions; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Available %s versions:|@%n", component))); + if (versions.isEmpty()) { + out.println(ansi(String.format(" @|yellow No %s versions found|@", component))); + return; + } + + TableList table = new TableList(out, 2, "Version", "Default"); + table.setPrefix(" "); + versions.forEach(v -> table.addRow(v.version(), v.isDefault() ? "yes" : "no")); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentAdded.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentAdded.java new file mode 100644 index 00000000..4e9d309f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentAdded.java @@ -0,0 +1,37 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.responses.agents; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.AgentDbDto; + +public class AgentAdded extends Response { + + public final String workspaceRef; + public final AgentDbDto agent; + + public AgentAdded(String workspaceRef, AgentDbDto agent) { + this.workspaceRef = workspaceRef; + this.agent = agent; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow New agent '%s' (%s) added at %s workspace|@%n", agent.getName(), agent.getId(), workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentDeleted.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentDeleted.java new file mode 100644 index 00000000..3d912b89 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentDeleted.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.responses.agents; + +import io.seqera.tower.cli.responses.Response; + +public class AgentDeleted extends Response { + + public final String workspaceRef; + public final String agentName; + + public AgentDeleted(String workspaceRef, String agentName) { + this.workspaceRef = workspaceRef; + this.agentName = agentName; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Agent '%s' deleted at %s workspace|@%n", agentName, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentUpdated.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentUpdated.java new file mode 100644 index 00000000..c2cb9d7c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentUpdated.java @@ -0,0 +1,38 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.responses.agents; + +import io.seqera.tower.cli.responses.Response; + +public class AgentUpdated extends Response { + + public final String workspaceRef; + public final String agentName; + public final String action; + + public AgentUpdated(String workspaceRef, String agentName, String action) { + this.workspaceRef = workspaceRef; + this.agentName = agentName; + this.action = action; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Agent '%s' %s at %s workspace|@%n", agentName, action, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java new file mode 100644 index 00000000..48c85352 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java @@ -0,0 +1,56 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.responses.agents; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.AgentDbDto; + +import java.io.PrintWriter; +import java.util.Objects; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; + +public class AgentView extends Response { + + public final String workspaceRef; + public final AgentDbDto agent; + + public AgentView(String workspaceRef, AgentDbDto agent) { + this.workspaceRef = workspaceRef; + this.agent = agent; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Agent at %s workspace:|@%n", workspaceRef))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", agent.getId()); + table.addRow("Name", agent.getName()); + table.addRow("Description", Objects.toString(agent.getDescription(), "")); + table.addRow("Status", agent.getStatus() == null ? "NA" : agent.getStatus().getValue()); + table.addRow("Created", formatDate(agent.getDateCreated())); + table.addRow("Updated", formatDate(agent.getLastUpdated())); + table.print(); + + out.println(ansi(String.format("%n @|bold Instructions:|@%n"))); + out.println(agent.getAgentInstructions()); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentsList.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentsList.java new file mode 100644 index 00000000..1a5082b4 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentsList.java @@ -0,0 +1,69 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.responses.agents; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.AgentDbDto; +import jakarta.annotation.Nullable; + +import java.io.PrintWriter; +import java.util.List; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; +import static io.seqera.tower.cli.utils.FormatHelper.formatDescription; + +public class AgentsList extends Response { + + public final String workspaceRef; + public final List agents; + + @JsonIgnore + @Nullable + private final PaginationInfo paginationInfo; + + public AgentsList(String workspaceRef, List agents, @Nullable PaginationInfo paginationInfo) { + this.workspaceRef = workspaceRef; + this.agents = agents; + this.paginationInfo = paginationInfo; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Agents at %s workspace:|@%n", workspaceRef))); + if (agents == null || agents.isEmpty()) { + out.println(ansi(" @|yellow No agents found|@")); + return; + } + + TableList table = new TableList(out, 5, "ID", "Name", "Description", "Status", "Updated"); + table.setPrefix(" "); + agents.forEach(agent -> table.addRow( + agent.getId(), + agent.getName(), + formatDescription(agent.getDescription(), 100), + agent.getStatus() == null ? "NA" : agent.getStatus().getValue(), + formatDate(agent.getLastUpdated()) + )); + table.print(); + PaginationInfo.addFooter(out, paginationInfo); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudioStarred.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudioStarred.java new file mode 100644 index 00000000..2a8ed4e2 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudioStarred.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.responses.studios; + +import io.seqera.tower.cli.responses.Response; + +public class StudioStarred extends Response { + + public final String userSuppliedStudioIdentifier; + public final String workspaceRef; + public final boolean starred; + + public StudioStarred(String userSuppliedStudioIdentifier, String workspaceRef, boolean starred) { + this.userSuppliedStudioIdentifier = userSuppliedStudioIdentifier; + this.workspaceRef = workspaceRef; + this.starred = starred; + } + + @Override + public String toString() { + if (starred) { + return ansi(String.format("%n @|yellow Studio %s starred at %s workspace.|@%n", userSuppliedStudioIdentifier, workspaceRef)); + } + return ansi(String.format("%n @|yellow Studio %s unstarred at %s workspace.|@%n", userSuppliedStudioIdentifier, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java index a74ff62f..3af4b146 100644 --- a/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java @@ -74,6 +74,7 @@ public void toString(PrintWriter out){ table.addRow("Memory allocated", config == null ? "-" : String.valueOf(config.getMemory())); table.addRow("Build reports", studio.getWaveBuildUrl() == null ? "NA" : studio.getWaveBuildUrl()); table.addRow("Private", studio.getIsPrivate() == null ? "NA" : String.valueOf(studio.getIsPrivate())); + table.addRow("Starred", studio.getStarred() == null ? "NA" : String.valueOf(studio.getStarred())); table.addRow("Lifespan (hours)", studio.getEffectiveLifespanHours() == null ? "Unlimited" : String.valueOf(studio.getEffectiveLifespanHours())); table.addRow("SSH enabled", config == null || config.getSshEnabled() == null ? "false" : String.valueOf(config.getSshEnabled())); diff --git a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java index 40f02332..39de6d49 100644 --- a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java @@ -17,6 +17,8 @@ package io.seqera.tower.cli; import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.responses.ComponentVersions; +import io.seqera.tower.cli.responses.ComponentVersions.ComponentVersion; import io.seqera.tower.cli.responses.InfoResponse; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -26,11 +28,13 @@ import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Properties; import static org.apache.commons.lang3.StringUtils.chop; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockserver.matchers.Times.exactly; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; @@ -156,6 +160,31 @@ void testInfoStatusUrlFail(MockServerClient mock) throws IOException { assertEquals(chop(new InfoResponse(0,-1,-1, opts).toString()), out.stdOut); } + @Test + void testVersions(MockServerClient mock) { + mock.reset(); + mock.when( + request().withMethod("GET").withPath("/platform/versions"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "nextflowVersions": [ + {"version": "25.10.1", "image": "nf-launcher:j21-25.10.1"}, + {"version": "26.04.6", "image": "nf-launcher:j21-26.04.6", "default": true} + ] + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "info", "versions"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(chop(new ComponentVersions("nextflow", List.of( + new ComponentVersion("25.10.1", false), + new ComponentVersion("26.04.6", true))).toString()), out.stdOut); + assertTrue(out.stdOut.contains("26.04.6"), out.stdOut); + } + private String getCliVersion() throws IOException { Properties props = new Properties(); props.load(this.getClass().getResourceAsStream("/META-INF/build-info.properties")); diff --git a/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java new file mode 100644 index 00000000..2d91a3ae --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java @@ -0,0 +1,216 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.agents; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.agents.AgentAdded; +import io.seqera.tower.cli.responses.agents.AgentDeleted; +import io.seqera.tower.cli.responses.agents.AgentUpdated; +import io.seqera.tower.cli.responses.agents.AgentView; +import io.seqera.tower.cli.responses.agents.AgentsList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.AgentDbDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockserver.client.MockServerClient; +import org.mockserver.model.MediaType; + +import java.io.IOException; +import java.util.List; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockserver.matchers.Times.exactly; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.model.JsonBody.json; + +class AgentsCmdTest extends BaseCmdTest { + + private static final String WSP_ID = "75887156211589"; + private static final String WSP_REF = "[organization1 / workspace1]"; + + private static final String AGENT = """ + { + "id": "agt_1", + "name": "fix-failed-runs", + "description": "Debug failed runs", + "agentInstructions": "Find the root cause", + "agentInstructionsTemplateId": "debug-template", + "status": "active", + "workspaceId": 75887156211589, + "createdBy": 1264, + "dateCreated": "2026-09-01T10:00:00Z", + "lastUpdated": "2026-09-02T10:00:00Z" + } + """; + + @BeforeEach + void init(MockServerClient mock) { + mock.reset(); + mock.when(request().withMethod("GET").withPath("/user-info"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON)); + mock.when(request().withMethod("GET").withPath("/user/1264/workspaces"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON)); + } + + private void mockDescribe(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/agents/agt_1").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT + "}").withContentType(MediaType.APPLICATION_JSON)); + } + + private void mockFindByName(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/agents") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("search", "fix-failed-runs") + .withQueryStringParameter("max", "100") + .withQueryStringParameter("offset", "0"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agents\":[" + AGENT.replace("fix-failed-runs", "fix-failed-runs-v2").replace("agt_1", "agt_2") + "," + AGENT + "],\"totalSize\":2}").withContentType(MediaType.APPLICATION_JSON)); + } + + private static AgentDbDto agent() throws JsonProcessingException { + return parseJson(AGENT, AgentDbDto.class); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testList(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when(request().withMethod("GET").withPath("/agents") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("search", "fix") + .withQueryStringParameter("max", "10") + .withQueryStringParameter("offset", "10"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agents\":[" + AGENT + "],\"totalSize\":11}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "list", "-w", WSP_ID, "-f", "fix", "--max", "10", "--page", "2"); + + assertOutput(format, out, new AgentsList(WSP_REF, List.of(agent()), PaginationInfo.from(null, 10, 2, 11L))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewByName(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockFindByName(mock); + mockDescribe(mock); + + ExecOut out = exec(format, mock, "agents", "view", "-w", WSP_ID, "-n", "fix-failed-runs"); + + assertOutput(format, out, new AgentView(WSP_REF, agent())); + } + + @Test + void testViewUnknownName(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/agents").withQueryStringParameter("search", "missing"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agents\":[],\"totalSize\":0}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(mock, "agents", "view", "-w", WSP_ID, "-n", "missing"); + + assertEquals(errorMessage(out.app, new TowerException("Unknown agent 'missing' at " + WSP_REF + " workspace")), out.stdErr); + assertEquals(1, out.exitCode); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testAdd(OutputType format, MockServerClient mock) throws IOException { + mock.when(request().withMethod("POST").withPath("/agents").withQueryStringParameter("workspaceId", WSP_ID) + .withBody(json(""" + { + "name": "fix-failed-runs", + "description": "Debug failed runs", + "agentInstructions": "Find the root cause" + } + """)), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT + "}").withContentType(MediaType.APPLICATION_JSON)); + + String instructions = tempFile("Find the root cause", "instructions", ".md"); + ExecOut out = exec(format, mock, "agents", "add", "-w", WSP_ID, "-n", "fix-failed-runs", "-d", "Debug failed runs", + "--instructions-file", instructions); + + assertOutput(format, out, new AgentAdded(WSP_REF, agent())); + } + + @Test + void testAddRequiresInstructions(MockServerClient mock) { + ExecOut out = exec(mock, "agents", "add", "-w", WSP_ID, "-n", "fix-failed-runs"); + + assertEquals(2, out.exitCode); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpdate(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockDescribe(mock); + // Fields not given on the command line keep their current value, as the API replaces the agent + mock.when(request().withMethod("PUT").withPath("/agents/agt_1").withQueryStringParameter("workspaceId", WSP_ID) + .withBody(json(""" + { + "name": "new-name", + "description": "Debug failed runs", + "agentInstructions": "New instructions", + "agentInstructionsTemplateId": "debug-template" + } + """)), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT.replace("\"fix-failed-runs\"", "\"new-name\"") + "}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "update", "-w", WSP_ID, "-i", "agt_1", "--new-name", "new-name", "--instructions", "New instructions"); + + assertOutput(format, out, new AgentUpdated(WSP_REF, "new-name", "updated")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDelete(OutputType format, MockServerClient mock) { + mockDescribe(mock); + mock.when(request().withMethod("DELETE").withPath("/agents/agt_1").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(204)); + + ExecOut out = exec(format, mock, "agents", "delete", "-w", WSP_ID, "-i", "agt_1"); + + assertOutput(format, out, new AgentDeleted(WSP_REF, "fix-failed-runs")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testEnable(OutputType format, MockServerClient mock) { + mockDescribe(mock); + mock.when(request().withMethod("POST").withPath("/agents/agt_1/enable").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT + "}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "enable", "-w", WSP_ID, "-i", "agt_1"); + + assertOutput(format, out, new AgentUpdated(WSP_REF, "fix-failed-runs", "enabled")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDisable(OutputType format, MockServerClient mock) { + mockFindByName(mock); + mockDescribe(mock); + mock.when(request().withMethod("POST").withPath("/agents/agt_1/disable").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT.replace("active", "inactive") + "}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "disable", "-w", WSP_ID, "-n", "fix-failed-runs"); + + assertOutput(format, out, new AgentUpdated(WSP_REF, "fix-failed-runs", "disabled")); + } +} diff --git a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java index f2cd2722..f46b3556 100644 --- a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java @@ -32,6 +32,7 @@ import io.seqera.tower.cli.responses.studios.StudioDeleted; import io.seqera.tower.cli.responses.studios.StudioCheckpointUpdated; import io.seqera.tower.cli.responses.studios.StudioLifespanExtended; +import io.seqera.tower.cli.responses.studios.StudioStarred; import io.seqera.tower.cli.responses.studios.StudioStartSubmitted; import io.seqera.tower.cli.responses.studios.StudioStopSubmitted; import io.seqera.tower.cli.responses.studios.StudioUpdated; @@ -2867,6 +2868,58 @@ private void mockWorkspace(MockServerClient mock) { ); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testStar(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("POST").withPath("/studios/3e8370e7/star").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"sessionId\": \"3e8370e7\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "studios", "star", "-w", "75887156211589", "-i", "3e8370e7"); + + assertOutput(format, out, new StudioStarred("3e8370e7", "[organization1 / workspace1]", true)); + mock.verify(request().withMethod("POST").withPath("/studios/3e8370e7/star"), VerificationTimes.once()); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUnstarByName(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("GET").withPath("/studios").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("studios/studios_list_response")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("DELETE").withPath("/studios/3e8370e7/star").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "studios", "unstar", "-w", "75887156211589", "-n", "studio-a66d"); + + assertOutput(format, out, new StudioStarred("studio-a66d", "[organization1 / workspace1]", false)); + mock.verify(request().withMethod("DELETE").withPath("/studios/3e8370e7/star"), VerificationTimes.once()); + } + + @Test + void testUnstarNotStarred(MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("DELETE").withPath("/studios/3e8370e7/star").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(404).withBody("{\"message\": \"Studio '3e8370e7' is not starred\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "studios", "unstar", "-w", "75887156211589", "-i", "3e8370e7"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Studio '3e8370e7' is not starred"), out.stdErr); + } + @ParameterizedTest @EnumSource(OutputType.class) void testExtend(OutputType format, MockServerClient mock) {