Skip to content
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This is the DNAstack client library and CLI, a Python package that provides both
## Development Commands

### Development Setup
- **IMPORTANT**: Run `eval "$(pyenv init -)" && pyenv activate dnastack-client` before any Python, make, uv, or git commit commands. This activates the correct pyenv virtualenv that has `uv` and other tools available.
- `make setup` - Set up development environment with uv (creates .venv and installs dependencies)

### Running the CLI
Expand Down
84 changes: 84 additions & 0 deletions dnastack/cli/commands/workbench/namespaces/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from dnastack.cli.commands.utils import MAX_RESULTS_ARG, PAGINATION_PAGE_ARG, PAGINATION_PAGE_SIZE_ARG
from dnastack.cli.commands.workbench.utils import get_user_client
from dnastack.http.session import ClientError
from dnastack.cli.core.command import formatted_command
from dnastack.cli.core.command_spec import ArgumentSpec, ArgumentType, CONTEXT_ARG, SINGLE_ENDPOINT_ID_ARG
from dnastack.cli.helpers.exporter import to_json, normalize
Expand Down Expand Up @@ -149,3 +150,86 @@ def set_active_namespace(context: Optional[str],
client = get_user_client(context, endpoint_id)
namespace = client.set_active_namespace(namespace_id)
click.echo(to_json(normalize(namespace)))

@formatted_command(
group=group,
name='create',
specs=[
ArgumentSpec(
name='name',
arg_names=['--name'],
help='The name of the namespace to create.',
required=True,
),
ArgumentSpec(
name='description',
arg_names=['--description'],
help='A description for the namespace. Defaults to the namespace name if omitted.',
required=False,
),
CONTEXT_ARG,
SINGLE_ENDPOINT_ID_ARG,
]
)
def create_namespace(context: Optional[str],
endpoint_id: Optional[str],
name: str,
description: Optional[str]):
"""
Create a new namespace

docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-create
"""

client = get_user_client(context, endpoint_id)
namespace = client.create_namespace(name=name, description=description)
click.echo(to_json(normalize(namespace)))

@formatted_command(
group=group,
name='update',
specs=[
ArgumentSpec(
name='namespace_id',
arg_type=ArgumentType.POSITIONAL,
help='The namespace ID to update.',
required=True,
),
ArgumentSpec(
name='name',
arg_names=['--name'],
help='The new name for the namespace.',
required=False,
),
ArgumentSpec(
name='description',
arg_names=['--description'],
help='The new description for the namespace.',
required=False,
),
CONTEXT_ARG,
SINGLE_ENDPOINT_ID_ARG,
]
)
def update_namespace(context: Optional[str],
endpoint_id: Optional[str],
namespace_id: str,
name: Optional[str],
description: Optional[str]):
"""
Update an existing namespace

docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-update
"""

if name is None and description is None:
raise click.UsageError("Specify at least one of --name or --description.")

client = get_user_client(context, endpoint_id)
try:
namespace = client.update_namespace(namespace_id=namespace_id, name=name, description=description)
except ClientError as e:
if e.response.status_code == 409:
raise click.ClickException("Namespace was modified by another user. Please retry.")
raise
click.echo(to_json(normalize(namespace)))
41 changes: 40 additions & 1 deletion dnastack/client/workbench/workbench_user_service/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
NamespaceMember,
NamespaceMemberListResponse,
AddMemberRequest,
NamespaceCreateRequest,
)
from dnastack.common.tracing import Span
from dnastack.http.session import HttpSession
from dnastack.http.session import HttpSession, JsonPatch


class NamespaceListResultLoader(WorkbenchResultLoader):
Expand Down Expand Up @@ -122,6 +123,44 @@ def set_active_namespace(self, namespace_id: str) -> Namespace:
)
return Namespace(**response.json())

def create_namespace(self, name: str, description: Optional[str] = None) -> Namespace:
"""Create a new namespace."""
body = NamespaceCreateRequest(name=name, description=description)
with self.create_http_session() as session:
response = session.post(
urljoin(self.endpoint.url, 'namespaces'),
json=body.dict(exclude_none=True),
)
return Namespace(**response.json())

def update_namespace(self,
namespace_id: str,
name: Optional[str] = None,
description: Optional[str] = None) -> Namespace:
"""Update a namespace using JSON Patch. Auto-fetches ETag for optimistic locking."""
patches = []
if name is not None:
patches.append(JsonPatch(op='replace', path='/name', value=name).dict())
if description is not None:
patches.append(JsonPatch(op='replace', path='/description', value=description).dict())

if not patches:
raise ValueError("At least one of name or description must be provided.")

with self.create_http_session() as session:
get_response = session.get(
urljoin(self.endpoint.url, f'namespaces/{namespace_id}')
)
etag = (get_response.headers.get('etag') or '').strip('"')
assert etag, f'GET namespaces/{namespace_id} does not provide ETag. Unable to update the namespace.'

patch_response = session.json_patch(
urljoin(self.endpoint.url, f'namespaces/{namespace_id}'),
headers={'If-Match': etag},
json=patches,
)
return Namespace(**patch_response.json())

def list_namespace_members(self,
namespace_id: str,
list_options: Optional[BaseListOptions] = None,
Expand Down
5 changes: 5 additions & 0 deletions dnastack/client/workbench/workbench_user_service/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ class AddMemberRequest(BaseModel):
role: str


class NamespaceCreateRequest(BaseModel):
name: str
description: Optional[str] = None


class NamespaceMemberListResponse(PaginatedResource):
members: List[NamespaceMember]

Expand Down
199 changes: 199 additions & 0 deletions docs/superpowers/specs/2026-03-13-namespace-create-update-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
---
title: Namespace Create & Update CLI Commands
date: 2026-03-13
repo: dnastack-client
services:
- dnastack-client
- workbench-user-service
tags:
- cli
- namespaces
- workbench
status: approved
decisions:
- Use PATCH API (not PUT) for update to avoid silent overwrites
- Auto-fetch ETag transparently — no --version flag exposed
- Omit initial_users from create — use members add separately
- Positional ID for update, consistent with defaults update pattern
- Flag-based PATCH — build JSON Patch ops from --name/--description flags
- Command names: create and update (matching existing CLI conventions)
---

# Namespace Create & Update CLI Commands

**ClickUp:** [CU-86b8vxxhv](https://app.clickup.com/t/86b8vxxhv)

## Goal

Add `namespaces create` and `namespaces update` commands to the dnastack-client CLI, backed by the workbench-user-service `POST /namespaces` and `PATCH /namespaces/{id}` endpoints.

## Prior Decisions

This design aligns with:
- [2026-02-20 Namespace CLI Commands Design](../../plans/2026-02-20-namespace-cli-commands-design.md) — extend `WorkbenchUserClient`, namespace endpoints are user-scoped
- [2026-02-26 Namespace Members Add/Remove Design](../../plans/2026-02-26-namespace-members-add-remove-design.md) — CLI validation patterns, error handling

## CLI Commands

### `namespaces create`

```
omics workbench namespaces create --name NAME [--description TEXT]
```

| Flag | Required | Description |
|------|----------|-------------|
| `--name` | Yes | Namespace name |
| `--description` | No | Namespace description. API defaults to the name if omitted. |
| `--context` | No | Select context |
| `--endpoint-id` | No | Select service endpoint |

- Calls `POST /namespaces` with `{"name": "...", "description": "..."}`
- Outputs created Namespace as JSON via `to_json(normalize(...))`
- Docstring includes `docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-create`

### `namespaces update`

```
omics workbench namespaces update ID [--name NAME] [--description TEXT]
```

| Arg/Flag | Type | Required | Description |
|----------|------|----------|-------------|
| `ID` | positional | Yes | Namespace ID to update |
| `--name` | option | No | New name |
| `--description` | option | No | New description |
| `--context` | option | No | Select context |
| `--endpoint-id` | option | No | Select service endpoint |

- At least one of `--name`/`--description` must be provided
- Flow: GET namespace (extract ETag) -> build JSON Patch ops -> `PATCH /namespaces/{id}` with `If-Match` and `Content-Type: application/json-patch+json`
- Outputs updated Namespace as JSON
- Docstring includes `docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-update`

## Design Decisions

### [CONFIRMED DECISION] Use PATCH API, not PUT [/CONFIRMED DECISION]

PUT requires sending all fields, which means auto-filling unchanged fields from a GET. If another user modifies a field between the GET and PUT, the stale value silently overwrites their change. PATCH only touches what the user specified, avoiding this class of bug.

### [CONFIRMED DECISION] Auto-fetch ETag transparently [/CONFIRMED DECISION]

The `update` command fetches the namespace before patching to get the current ETag for the `If-Match` header. No `--version` flag is exposed. This is consistent with how other CLI commands work — they don't expose API internals like version tokens.

### [CONFIRMED DECISION] Omit initial_users from create [/CONFIRMED DECISION]

The `POST /namespaces` endpoint supports an `initial_users` field to seed members on creation. We omit this from the CLI — users can call `members add` after creating. This keeps the command simple and avoids a compound flag format like `--initial-user email:ROLE`.

### [CONFIRMED DECISION] Positional ID for update [/CONFIRMED DECISION]

The existing `defaults update` command uses a positional ID (`default_id`). We follow that pattern for `namespaces update`.

### [CONFIRMED DECISION] Command names: create and update [/CONFIRMED DECISION]

The CLI already uses `create` (workflows, versions, defaults, dependencies, publisher collections) and `update` (defaults, dependencies) as command names. `edit` was considered but typically implies opening an editor (like `kubectl edit`). `update` maps naturally to the PATCH verb and matches existing conventions.

### [AI DECISION] Flag-based JSON Patch construction [/AI DECISION]

The client builds JSON Patch operations from non-None flags rather than accepting raw JSON Patch input. Users shouldn't need to know RFC 6902 syntax. Only two fields are mutable (`name`, `description`), so this is a complete mapping. Reuses the existing `JsonPatch` model from `dnastack/http/session.py` for consistency with `CollectionServiceClient.patch()`.

## Client Layer

### New methods on `WorkbenchUserClient`

Both new commands are registered inside `init_namespace_commands(group)` in `commands.py`, following the pattern of `list`, `describe`, `get-active`, and `set-active`.

**`create_namespace(name: str, description: Optional[str] = None) -> Namespace`**
- `POST /namespaces` with `Content-Type: application/json`
- Body: `NamespaceCreateRequest` serialized via `.dict(exclude_none=True)` (pydantic v1)
- Returns `Namespace(**response.json())`

**`update_namespace(namespace_id: str, name: Optional[str] = None, description: Optional[str] = None) -> Namespace`**
- Uses a single `self.create_http_session()` context manager for both requests (avoids re-authentication)
- Step 1: `GET /namespaces/{id}` — extract ETag from `response.headers['ETag']`
- Step 2: Build JSON Patch ops using the existing `JsonPatch` model from `dnastack/http/session.py`:
```python
patches = []
if name is not None:
patches.append(JsonPatch(op='replace', path='/name', value=name).dict())
if description is not None:
patches.append(JsonPatch(op='replace', path='/description', value=description).dict())
```
- Step 3: `PATCH /namespaces/{id}` with headers `If-Match: "<etag>"` and `Content-Type: application/json-patch+json`, body is the patches list
- Returns `Namespace(**response.json())`
- The client lets `ClientError` propagate on 409 — the CLI layer catches and translates it

### New model

```python
class NamespaceCreateRequest(BaseModel):
name: str
description: Optional[str] = None
```

No new model needed for the PATCH payload — reuses `JsonPatch` from `dnastack/http/session.py`.

### Files modified

- `dnastack/client/workbench/workbench_user_service/models.py` — add `NamespaceCreateRequest`
- `dnastack/client/workbench/workbench_user_service/client.py` — add `create_namespace`, `update_namespace` methods
- `dnastack/cli/commands/workbench/namespaces/commands.py` — add `create` and `update` commands in `init_namespace_commands`
- `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py` — add test classes
- `tests/unit/client/workbench/workbench_user_service/test_namespace_models.py` — add model tests

## Error Handling

### Create
- 400 (name blank) — surfaces as `HttpError` with API message
- 401/403 — existing authenticator system handles these

### Update
- 404 (namespace not found) — raised during auto-fetch GET as `ClientError`
- 400 (name blank, name > 256 chars, invalid patch) — surfaces as `HttpError`
- 409 (version conflict) — `ClientError` propagates from the client layer; the CLI command catches it, checks `response.status_code == 409`, and raises `click.ClickException("Namespace was modified by another user. Please retry.")`
- 401/403 — existing authenticator system

### CLI validation
- `update` with neither `--name` nor `--description` -> `click.UsageError("Specify at least one of --name or --description.")`

## Tests

### Unit tests — Client models
- `NamespaceCreateRequest` serialization with and without description

### Unit tests — CLI commands
**`TestCreateCommand`:**
- Success with name only
- Success with name and description
- Output is valid JSON
- Missing `--name` -> non-zero exit code

**`TestUpdateCommand`:**
- Success with `--name` only — patch contains only name op
- Success with `--description` only — patch contains only description op
- Success with both flags
- Neither flag -> `UsageError`
- 409 Conflict -> user-friendly error message

### E2E tests
- Create namespace, verify in list
- Update name, verify via describe
- Update description only, verify name unchanged
- Update with invalid ID -> 404 `ClientError` from the GET step

## Product Documentation

Two new pages in `docs/cli/reference/workbench/`:

### `namespaces-create.md`
- Synopsis: `omics workbench namespaces create --name NAME [--description TEXT]`
- Note that caller is auto-added as ADMIN
- Examples: name only, name + description

### `namespaces-update.md`
- Synopsis: `omics workbench namespaces update ID [--name NAME] [--description TEXT]`
- Note that at least one flag is required
- Examples: update name, update description, update both

### SUMMARY.md
- Add entries for both new pages in the namespace section
Loading
Loading