dreadnode 2.0.45__py3-none-any.whl → 2.0.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. dreadnode/agents/agent.py +15 -4
  2. dreadnode/agents/engines/base.py +4 -2
  3. dreadnode/agents/engines/claude_code.py +5 -1
  4. dreadnode/agents/engines/native.py +3 -1
  5. dreadnode/app/api/client.py +211 -4
  6. dreadnode/app/api/models.py +15 -0
  7. dreadnode/app/cli/capability.py +17 -1
  8. dreadnode/app/cli/environment.py +39 -7
  9. dreadnode/app/cli/main.py +14 -0
  10. dreadnode/app/cli/workflow.py +593 -0
  11. dreadnode/app/client/managed_client.py +34 -6
  12. dreadnode/app/client/runtime_client.py +45 -2
  13. dreadnode/app/config.py +31 -0
  14. dreadnode/app/print_mode.py +24 -4
  15. dreadnode/app/server/app.py +177 -30
  16. dreadnode/app/server/capability_manager.py +57 -16
  17. dreadnode/app/server/runtime_events.py +3 -0
  18. dreadnode/app/server/session_hydrator.py +1 -1
  19. dreadnode/app/server/turn_coordinator.py +26 -0
  20. dreadnode/app/server/websocket.py +20 -2
  21. dreadnode/app/server/workflow_manager.py +207 -0
  22. dreadnode/app/tui/app.py +49 -44
  23. dreadnode/app/tui/command_dispatcher.py +24 -6
  24. dreadnode/app/tui/commands.py +5 -0
  25. dreadnode/app/tui/dreadnode.tcss +5 -0
  26. dreadnode/app/tui/widgets/__init__.py +0 -13
  27. dreadnode/app/tui/widgets/composer.py +108 -27
  28. dreadnode/app/tui/widgets/context_bar.py +2 -1
  29. dreadnode/app/tui/widgets/conversation.py +142 -3
  30. dreadnode/app/tui/widgets/selection.py +207 -0
  31. dreadnode/app/tui/widgets/tool.py +31 -16
  32. dreadnode/builtin_capabilities/dreadnode/agents/dreadnode.md +3 -1
  33. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/SKILL.md +29 -12
  34. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/assets/workflows/agent_review.py +35 -0
  35. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/assets/workflows/branch.py +41 -0
  36. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/assets/workflows/fanout.py +40 -0
  37. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/assets/workflows/fork.py +52 -0
  38. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/assets/workflows/linear.py +28 -0
  39. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/quality-bar.md +17 -0
  40. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/INDEX.md +1 -0
  41. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/capabilities/agent-output.md +0 -4
  42. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/capabilities/manifest.md +42 -10
  43. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/capabilities/overview.md +13 -11
  44. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/capabilities/publishing.md +3 -1
  45. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/capabilities/workflows.md +293 -0
  46. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/sdk/capabilities.md +56 -5
  47. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/workflows-authoring.md +242 -0
  48. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/SKILL.md +4 -0
  49. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/INDEX.md +1 -0
  50. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/airt.md +3 -3
  51. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/capability.md +1 -1
  52. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/env.md +9 -5
  53. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/judge.md +1 -1
  54. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/optimize.md +1 -1
  55. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/train.md +2 -2
  56. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/workflow.md +186 -0
  57. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/worlds.md +1 -1
  58. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/capabilities/overview.md +13 -11
  59. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/credits.md +8 -0
  60. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/organizations.md +1 -0
  61. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/settings.md +6 -6
  62. dreadnode/capabilities/loader.py +22 -1
  63. dreadnode/capabilities/types.py +18 -0
  64. dreadnode/capabilities/worker_runner.py +4 -4
  65. dreadnode/packaging/manifest.py +17 -0
  66. dreadnode/packaging/oci.py +60 -0
  67. dreadnode/tracing/constants.py +7 -0
  68. dreadnode/tracing/span.py +34 -0
  69. dreadnode/workflows/__init__.py +118 -0
  70. dreadnode/workflows/compile.py +1330 -0
  71. dreadnode/workflows/context.py +115 -0
  72. dreadnode/workflows/errors.py +87 -0
  73. dreadnode/workflows/host.py +856 -0
  74. dreadnode/workflows/loader.py +163 -0
  75. dreadnode/workflows/workflow.py +198 -0
  76. {dreadnode-2.0.45.dist-info → dreadnode-2.0.46.dist-info}/METADATA +1 -1
  77. {dreadnode-2.0.45.dist-info → dreadnode-2.0.46.dist-info}/RECORD +86 -67
  78. dreadnode_workflow_core/__init__.py +122 -0
  79. dreadnode_workflow_core/events.py +148 -0
  80. dreadnode_workflow_core/facts.py +124 -0
  81. dreadnode_workflow_core/scheduler.py +486 -0
  82. dreadnode_workflow_core/state.py +269 -0
  83. dreadnode_workflow_core/topology.py +252 -0
  84. dreadnode/app/tui/widgets/header_bar.py +0 -59
  85. dreadnode/app/tui/widgets/permission_prompt.py +0 -112
  86. dreadnode/app/tui/widgets/prompt_info.py +0 -64
  87. dreadnode/app/tui/widgets/session_sidebar.py +0 -111
  88. dreadnode/app/tui/widgets/throbber.py +0 -87
  89. {dreadnode-2.0.45.dist-info → dreadnode-2.0.46.dist-info}/WHEEL +0 -0
  90. {dreadnode-2.0.45.dist-info → dreadnode-2.0.46.dist-info}/entry_points.txt +0 -0
  91. {dreadnode-2.0.45.dist-info → dreadnode-2.0.46.dist-info}/licenses/LICENSE +0 -0
dreadnode/agents/agent.py CHANGED
@@ -838,7 +838,10 @@ class Agent(Executor[AgentEvent, Trajectory]):
838
838
  raise
839
839
 
840
840
  async def _stream(
841
- self, trajectory: Trajectory | None = None
841
+ self,
842
+ trajectory: Trajectory | None = None,
843
+ *,
844
+ message_metadata: dict[str, t.Any] | None = None,
842
845
  ) -> t.AsyncGenerator[AgentEvent, None]:
843
846
  """Drive the loop through the resolved :class:`AgentEngine`.
844
847
 
@@ -855,6 +858,7 @@ class Agent(Executor[AgentEvent, Trajectory]):
855
858
  goal=self._current_input,
856
859
  dispatch=self._dispatch,
857
860
  permission=self._permission_bridge,
861
+ message_metadata=message_metadata,
858
862
  )
859
863
  async for event in engine.run_loop(ctx):
860
864
  yield event
@@ -864,7 +868,10 @@ class Agent(Executor[AgentEvent, Trajectory]):
864
868
  return resolve_engine(self.engine)
865
869
 
866
870
  async def _native_run_loop(
867
- self, trajectory: Trajectory | None = None
871
+ self,
872
+ trajectory: Trajectory | None = None,
873
+ *,
874
+ message_metadata: dict[str, t.Any] | None = None,
868
875
  ) -> t.AsyncGenerator[AgentEvent, None]:
869
876
  """
870
877
  Core agent execution loop with inline tracing.
@@ -882,7 +889,7 @@ class Agent(Executor[AgentEvent, Trajectory]):
882
889
  Message(
883
890
  "user",
884
891
  str(self._current_input),
885
- metadata={"agent": self.name, "model": self.model_name},
892
+ metadata={**(message_metadata or {}), "agent": self.name, "model": self.model_name},
886
893
  ),
887
894
  ]
888
895
  messages = inject_system_content(messages, self.instructions)
@@ -1316,12 +1323,14 @@ class Agent(Executor[AgentEvent, Trajectory]):
1316
1323
  *,
1317
1324
  reset: bool = True,
1318
1325
  trajectory: Trajectory | None = None,
1326
+ message_metadata: dict[str, t.Any] | None = None,
1319
1327
  ) -> t.AsyncIterator[t.AsyncGenerator[AgentEvent, None]]:
1320
1328
  """
1321
1329
  Stream agent execution.
1322
1330
 
1323
1331
  Args:
1324
1332
  goal: Input message for the agent.
1333
+ message_metadata: Informational metadata attached to this turn's user message.
1325
1334
  reset: If True, start new conversation. If False, continue existing.
1326
1335
  Ignored when *trajectory* is provided.
1327
1336
  trajectory: External trajectory to operate on. When provided the
@@ -1369,7 +1378,9 @@ class Agent(Executor[AgentEvent, Trajectory]):
1369
1378
  with ctx as parent_span:
1370
1379
 
1371
1380
  async def _events() -> t.AsyncGenerator[AgentEvent, None]:
1372
- async for event in self._stream(active_trajectory):
1381
+ async for event in self._stream(
1382
+ active_trajectory, message_metadata=message_metadata
1383
+ ):
1373
1384
  # Emit agent-level events to parent span
1374
1385
  if parent_span and isinstance(
1375
1386
  event, (AgentStart, AgentEnd, AgentStalled, AgentError)
@@ -104,8 +104,8 @@ class PermissionBridge(t.Protocol):
104
104
  class EngineContext:
105
105
  """Everything an engine needs to run one turn.
106
106
 
107
- The native engine uses only ``agent`` + ``trajectory`` (it reaches the rest
108
- through ``agent``). Foreign engines additionally use ``goal``, ``dispatch``
107
+ The native engine uses ``agent``, ``trajectory``, and ``message_metadata``.
108
+ Foreign engines additionally use ``goal``, ``dispatch``
109
109
  (to run observational hooks on translated events), and ``permission``.
110
110
  """
111
111
 
@@ -116,6 +116,8 @@ class EngineContext:
116
116
  """Run an event through the agent's hooks (metrics + reactions). Foreign
117
117
  engines call this per translated event; the native engine dispatches inline."""
118
118
  permission: "PermissionBridge | None" = None
119
+ message_metadata: dict[str, t.Any] | None = None
120
+ """Informational metadata for the initiating user message, never authorization."""
119
121
 
120
122
 
121
123
  class AgentEngine(ABC):
@@ -393,7 +393,11 @@ class ClaudeCodeEngine(AgentEngine):
393
393
  Message(
394
394
  "user",
395
395
  str(ctx.goal or ""),
396
- metadata={"agent": agent.name, "model": agent.model_name},
396
+ metadata={
397
+ **(ctx.message_metadata or {}),
398
+ "agent": agent.name,
399
+ "model": agent.model_name,
400
+ },
397
401
  )
398
402
  )
399
403
  step_messages.append(assistant_msg)
@@ -22,5 +22,7 @@ class NativeEngine(AgentEngine):
22
22
  dispatches_internally = True
23
23
 
24
24
  async def run_loop(self, ctx: EngineContext) -> "t.AsyncIterator[AgentEvent]":
25
- async for event in ctx.agent._native_run_loop(ctx.trajectory):
25
+ async for event in ctx.agent._native_run_loop(
26
+ ctx.trajectory, message_metadata=ctx.message_metadata
27
+ ):
26
28
  yield event
@@ -3325,10 +3325,12 @@ class ApiClient:
3325
3325
  """Find a session's workspace and project by searching across all org workspaces.
3326
3326
 
3327
3327
  Returns the full API response payload (normally containing a
3328
- ``session`` dict with ``workspace`` and ``project_name`` keys) from the
3329
- first workspace where the session is found, or ``None`` if not found in
3330
- any workspace. Used by ``--resume`` to locate sessions that belong to a
3331
- different workspace than the current profile default.
3328
+ ``session`` dict with ``workspace``, ``project_key`` and
3329
+ ``project_name`` keys) from the first workspace where the session is
3330
+ found, or ``None`` if not found in any workspace. Used by ``--resume``
3331
+ to locate sessions that belong to a different workspace than the
3332
+ current profile default — which scopes off ``project_key``, the
3333
+ addressable slug, not the ``project_name`` display label.
3332
3334
 
3333
3335
  Raises :class:`AuthenticationError` on 401 so callers can surface
3334
3336
  the auth failure rather than silently returning "not found".
@@ -4274,6 +4276,211 @@ class ApiClient:
4274
4276
  )
4275
4277
  return t.cast("dict[str, t.Any]", response.json())
4276
4278
 
4279
+ # ------------------------------------------------------------------
4280
+ # Workflows - authored graphs, runs, and approvals
4281
+ # ------------------------------------------------------------------
4282
+
4283
+ def list_workflow_definitions(
4284
+ self,
4285
+ org: str,
4286
+ workspace: str,
4287
+ *,
4288
+ capability: str | None = None,
4289
+ name: str | None = None,
4290
+ agent: str | None = None,
4291
+ limit: int = 100,
4292
+ ) -> list[dict[str, t.Any]]:
4293
+ """GET workspace workflow definitions."""
4294
+ params: dict[str, t.Any] = {"limit": limit}
4295
+ params.update(
4296
+ {
4297
+ key: value
4298
+ for key, value in (
4299
+ ("capability", capability),
4300
+ ("name", name),
4301
+ ("agent", agent),
4302
+ )
4303
+ if value is not None
4304
+ }
4305
+ )
4306
+ response = self.request(
4307
+ "GET",
4308
+ f"/org/{org}/ws/{workspace}/workflows/definitions",
4309
+ params=params,
4310
+ )
4311
+ return t.cast("list[dict[str, t.Any]]", response.json())
4312
+
4313
+ def get_workflow_definition(
4314
+ self, org: str, workspace: str, definition_id: str
4315
+ ) -> dict[str, t.Any]:
4316
+ """Get a workspace-visible definition with its full topology."""
4317
+ response = self.request(
4318
+ "GET",
4319
+ f"/org/{org}/ws/{workspace}/workflows/definitions/{definition_id}",
4320
+ )
4321
+ return t.cast("dict[str, t.Any]", response.json())
4322
+
4323
+ def create_workflow_run(
4324
+ self,
4325
+ org: str,
4326
+ workspace: str,
4327
+ *,
4328
+ definition_id: str,
4329
+ project_id: str,
4330
+ input_data: dict[str, t.Any] | None = None,
4331
+ host_kind: str = "runtime",
4332
+ runtime_id: str | None = None,
4333
+ ) -> dict[str, t.Any]:
4334
+ """Start a workflow run in a workspace project."""
4335
+ if host_kind == "runtime" and runtime_id is None:
4336
+ raise ValueError("runtime_id is required for runtime-hosted workflows")
4337
+ if host_kind == "local" and runtime_id is not None:
4338
+ raise ValueError("runtime_id is not allowed for local workflows")
4339
+ payload: dict[str, t.Any] = {
4340
+ "definition_id": definition_id,
4341
+ "project_id": project_id,
4342
+ "input": input_data or {},
4343
+ "host_kind": host_kind,
4344
+ }
4345
+ if runtime_id is not None:
4346
+ payload["runtime_id"] = runtime_id
4347
+ response = self.request(
4348
+ "POST",
4349
+ f"/org/{org}/ws/{workspace}/workflows/runs",
4350
+ json_data=payload,
4351
+ )
4352
+ return t.cast("dict[str, t.Any]", response.json())
4353
+
4354
+ def list_workflow_runs(
4355
+ self,
4356
+ org: str,
4357
+ workspace: str,
4358
+ *,
4359
+ project_id: str | None = None,
4360
+ runtime_id: str | None = None,
4361
+ definition_id: str | None = None,
4362
+ status: str | None = None,
4363
+ limit: int = 100,
4364
+ ) -> list[dict[str, t.Any]]:
4365
+ """List workflow runs in a workspace project."""
4366
+ if project_id is None and runtime_id is None:
4367
+ raise ValueError("project_id is required unless polling by runtime_id")
4368
+ params: dict[str, t.Any] = {"limit": limit}
4369
+ if project_id is not None:
4370
+ params["project_id"] = project_id
4371
+ if runtime_id is not None:
4372
+ params["runtime_id"] = runtime_id
4373
+ if definition_id is not None:
4374
+ params["definition_id"] = definition_id
4375
+ if status is not None:
4376
+ params["status"] = status
4377
+ response = self.request(
4378
+ "GET",
4379
+ f"/org/{org}/ws/{workspace}/workflows/runs",
4380
+ params=params,
4381
+ )
4382
+ return t.cast("list[dict[str, t.Any]]", response.json())
4383
+
4384
+ def claim_workflow_run(
4385
+ self,
4386
+ org: str,
4387
+ workspace: str,
4388
+ run_id: str,
4389
+ *,
4390
+ execution_id: str,
4391
+ runtime_id: str | None = None,
4392
+ ) -> dict[str, t.Any]:
4393
+ """Atomically claim one pending workflow run for this host."""
4394
+ payload: dict[str, t.Any] = {"execution_id": execution_id}
4395
+ if runtime_id is not None:
4396
+ payload["runtime_id"] = runtime_id
4397
+ response = self.request(
4398
+ "POST",
4399
+ f"/org/{org}/ws/{workspace}/workflows/runs/{run_id}/claim",
4400
+ json_data=payload,
4401
+ )
4402
+ return t.cast("dict[str, t.Any]", response.json())
4403
+
4404
+ def get_workflow_run(self, org: str, workspace: str, run_id: str) -> dict[str, t.Any]:
4405
+ """Get a workflow run with nodes and pinned topology."""
4406
+ response = self.request("GET", f"/org/{org}/ws/{workspace}/workflows/runs/{run_id}")
4407
+ return t.cast("dict[str, t.Any]", response.json())
4408
+
4409
+ def append_workflow_facts(
4410
+ self,
4411
+ org: str,
4412
+ workspace: str,
4413
+ run_id: str,
4414
+ facts: list[dict[str, t.Any]],
4415
+ *,
4416
+ execution_id: str,
4417
+ ) -> dict[str, t.Any]:
4418
+ """Append host-sequenced facts to a workspace workflow run."""
4419
+ response = self.request(
4420
+ "POST",
4421
+ f"/org/{org}/ws/{workspace}/workflows/runs/{run_id}/facts",
4422
+ json_data={"execution_id": execution_id, "facts": facts},
4423
+ )
4424
+ return t.cast("dict[str, t.Any]", response.json())
4425
+
4426
+ def heartbeat_workflow_run(
4427
+ self,
4428
+ org: str,
4429
+ workspace: str,
4430
+ run_id: str,
4431
+ *,
4432
+ execution_id: str,
4433
+ ) -> dict[str, t.Any]:
4434
+ """Report workflow-run liveness."""
4435
+ response = self.request(
4436
+ "POST",
4437
+ f"/org/{org}/ws/{workspace}/workflows/runs/{run_id}/heartbeat",
4438
+ json_data={"execution_id": execution_id},
4439
+ )
4440
+ return t.cast("dict[str, t.Any]", response.json())
4441
+
4442
+ def cancel_workflow_run(
4443
+ self, org: str, workspace: str, run_id: str, *, project_id: str
4444
+ ) -> dict[str, t.Any]:
4445
+ """Cancel a workflow run in one project."""
4446
+ response = self.request(
4447
+ "POST",
4448
+ f"/org/{org}/ws/{workspace}/workflows/runs/{run_id}/cancel",
4449
+ params={"project_id": project_id},
4450
+ )
4451
+ return t.cast("dict[str, t.Any]", response.json())
4452
+
4453
+ def list_workflow_approvals(
4454
+ self, org: str, workspace: str, *, project_id: str, limit: int = 100
4455
+ ) -> list[dict[str, t.Any]]:
4456
+ """List pending workflow approvals in a workspace project."""
4457
+ response = self.request(
4458
+ "GET",
4459
+ f"/org/{org}/ws/{workspace}/workflows/approvals",
4460
+ params={"project_id": project_id, "limit": limit},
4461
+ )
4462
+ return t.cast("list[dict[str, t.Any]]", response.json())
4463
+
4464
+ def decide_workflow_approval(
4465
+ self,
4466
+ org: str,
4467
+ workspace: str,
4468
+ approval_id: str,
4469
+ *,
4470
+ outcome: str,
4471
+ note: str | None = None,
4472
+ ) -> dict[str, t.Any]:
4473
+ """Decide a workspace workflow approval."""
4474
+ payload: dict[str, t.Any] = {"outcome": outcome}
4475
+ if note is not None:
4476
+ payload["note"] = note
4477
+ response = self.request(
4478
+ "POST",
4479
+ f"/org/{org}/ws/{workspace}/workflows/approvals/{approval_id}/decision",
4480
+ json_data=payload,
4481
+ )
4482
+ return t.cast("dict[str, t.Any]", response.json())
4483
+
4277
4484
 
4278
4485
  def create_api_client(*, profile: str | None = None) -> ApiClient:
4279
4486
  """Create an authenticated API client using stored API key configuration data."""
@@ -95,11 +95,18 @@ class PlatformConfig(BaseModel):
95
95
  # web UI defaults it the other way and fails closed, but nothing in the SDK
96
96
  # gates on it today — it is mirrored so the drift check stays meaningful.
97
97
  airgapped: bool = False
98
+ email_configured: bool = False
98
99
  recaptcha_enabled: bool = False
99
100
  recaptcha_site_key: str | None = None
101
+ account_defender_enabled: bool = False
102
+ account_defender_site_key: str | None = None
100
103
  credits_enabled: bool = True
101
104
  feedback_enabled: bool = False
102
105
  integrations_enabled: bool = True
106
+ # Whether the operator has enabled the Slack integration for this
107
+ # deployment. Absent (False) on older platforms, which have no Slack routes
108
+ # at all, so a client that does not receive it shows nothing (SLK-GATE-003).
109
+ slack_enabled: bool = False
103
110
  # False on older platforms whose /feedback rejects diagnostic-report fields.
104
111
  feedback_diagnostics: bool = False
105
112
  website_url: str
@@ -299,6 +306,14 @@ class Organization(BaseModel):
299
306
  """URL-friendly identifier for the organization."""
300
307
  description: str | None = None
301
308
  """Description of the organization."""
309
+ avatar_preset: t.Literal["slate", "blue", "violet", "green", "amber"] | None = None
310
+ """Selected avatar color preset."""
311
+ avatar_source: t.Literal["image", "preset"] | None = None
312
+ """Which avatar is active; None renders the organization name initial."""
313
+ avatar_image_available: bool = False
314
+ """Whether the organization has an uploaded avatar image."""
315
+ avatar_image_revision: str | None = None
316
+ """Revision of the uploaded avatar image."""
302
317
  is_active: bool = True
303
318
  """Is the organization active?"""
304
319
  allow_external_invites: bool = False
@@ -350,7 +350,22 @@ def _summarize_capability(p: dict[str, t.Any]) -> str:
350
350
  visibility = "public" if p.get("is_public") else "private"
351
351
  description = p.get("description")
352
352
  suffix = f" [dim]-[/dim] {description}" if description else ""
353
- return f"[cyan]{name}[/cyan]@[dim]{version}[/dim] {_visibility_markup(visibility)}{suffix}"
353
+ secrets = p.get("secrets")
354
+ required_secrets = (
355
+ [
356
+ secret.get("name")
357
+ for secret in secrets
358
+ if isinstance(secret, dict) and secret.get("required", True)
359
+ ]
360
+ if isinstance(secrets, list)
361
+ else []
362
+ )
363
+ credential_notice = (
364
+ f" [yellow]requires credentials: {', '.join(str(secret) for secret in required_secrets)}[/yellow]"
365
+ if required_secrets
366
+ else ""
367
+ )
368
+ return f"[cyan]{name}[/cyan]@[dim]{version}[/dim] {_visibility_markup(visibility)}{suffix}{credential_notice}"
354
369
 
355
370
 
356
371
  _CAPABILITY_LIST_ROW_FIELDS: tuple[str, ...] = (
@@ -360,6 +375,7 @@ _CAPABILITY_LIST_ROW_FIELDS: tuple[str, ...] = (
360
375
  "author_name",
361
376
  "license",
362
377
  "component_counts",
378
+ "secrets",
363
379
  "version_count",
364
380
  "versions",
365
381
  "created_at",
@@ -11,6 +11,7 @@ import typing as t
11
11
 
12
12
  import cyclopts
13
13
 
14
+ from dreadnode.app.api.client import NotFoundError
14
15
  from dreadnode.app.cli.args import PlatformScopeArgs
15
16
  from dreadnode.app.cli.shared import (
16
17
  _FLAG_STATE,
@@ -137,7 +138,33 @@ def _print_environment_detail(payload: dict[str, t.Any]) -> None:
137
138
  # ---------------------------------------------------------------------------
138
139
 
139
140
 
140
- @cli.command()
141
+ def _create_env_with_hint(
142
+ api: t.Any, org: str, workspace: str, body: dict[str, t.Any], task_ref: str
143
+ ) -> dict[str, t.Any]:
144
+ """Create the environment, adding a resolution hint to a task-not-found 404.
145
+
146
+ A bare name resolves to a task in the caller's org or any public task; a task
147
+ owned by another org resolves only when it is public or owned by the caller
148
+ (server-side visibility rule). We do not retry client-side - a public task
149
+ already resolves by bare name, and a private cross-org task 404s the same way
150
+ whether or not it is qualified. Only a task-not-found 404 gets the hint; any
151
+ other 404 (e.g. a bad ``--project-id``) keeps the server's own detail message.
152
+ """
153
+ try:
154
+ return api.create_environment(org, workspace, body)
155
+ except NotFoundError as exc:
156
+ # provision() can 404 on the project lookup (bad --project-id) before it
157
+ # ever resolves the task, so only rewrite genuine task-not-found errors.
158
+ if "task not found" not in str(exc).lower():
159
+ raise
160
+ raise NotFoundError(
161
+ f"Task '{task_ref}' not found. A bare name resolves to a task in your org or "
162
+ f"any public task; a task owned by another org must be public or owned by you. "
163
+ f"Check the name and version, or qualify it as <org>/<name>."
164
+ ) from exc
165
+
166
+
167
+ @cli.command(alias="provision")
141
168
  def create(
142
169
  task_ref: str,
143
170
  *,
@@ -207,14 +234,19 @@ def create(
207
234
  as_json: t.Annotated[bool, cyclopts.Parameter(name="--json", negative=())] = False,
208
235
  platform: PlatformScopeArgs = PlatformScopeArgs(),
209
236
  ) -> None:
210
- """Provision a task environment.
237
+ """Provision a task environment. (Alias: ``dreadnode env provision``.)
211
238
 
212
239
  ``task_ref`` follows the canonical ``[org/]name[@version]`` format:
213
240
 
214
- - ``my-task`` — latest visible version
215
- - ``my-task@1.0.0`` — exact version
216
- - ``acme/my-task`` — cross-org (must be public or owned by you)
217
- - ``acme/my-task@1.0.0`` — cross-org exact version
241
+ - ``my-task`` : latest version visible to you
242
+ - ``my-task@1.0.0`` : exact version
243
+ - ``acme/my-task`` : cross-org (must be public or owned by you)
244
+ - ``acme/my-task@1.0.0`` : cross-org exact version
245
+
246
+ A bare name resolves to a task in your org or any public task, so bundled
247
+ public targets (e.g. ``ml-extraction-fraud-tabular``, ``finops-mesh``) work by
248
+ bare name. A task owned by another org resolves only when it is public or
249
+ owned by you; otherwise qualify it as ``<org>/<name>``.
218
250
 
219
251
  Use ``--input name=value`` repeatedly to bind template variables (values
220
252
  are JSON-decoded when possible, falling back to plain strings).
@@ -245,7 +277,7 @@ def create(
245
277
  if env_models is not None:
246
278
  body["model_overrides"] = env_models
247
279
 
248
- payload = api.create_environment(profile.org_key, profile.workspace_key, body)
280
+ payload = _create_env_with_hint(api, profile.org_key, profile.workspace_key, body, task_ref)
249
281
  env_id = str(payload.get("id", ""))
250
282
 
251
283
  if wait and env_id:
dreadnode/app/cli/main.py CHANGED
@@ -149,6 +149,7 @@ _LAZY_COMMANDS: dict[str, tuple[str, str | None]] = {
149
149
  "task": ("dreadnode.app.cli.task", None),
150
150
  "task-set": ("dreadnode.app.cli.task_set", None),
151
151
  "train": ("dreadnode.app.cli.train", None),
152
+ "workflow": ("dreadnode.app.cli.workflow", None),
152
153
  "worlds": ("dreadnode.app.cli.worlds", None),
153
154
  }
154
155
 
@@ -336,9 +337,22 @@ def _run_print(tui_args: TuiArgs) -> None:
336
337
  if not tui_args.prompt:
337
338
  raise ValueError("--print requires --prompt")
338
339
 
340
+ # Resolve the same way the TUI does, so ``--profile`` and the scope
341
+ # flags reach the headless runtime instead of being re-derived from
342
+ # whichever profile happens to be active on disk.
343
+ from dreadnode.app.config import ProfileError
344
+
345
+ try:
346
+ profile = tui_args.resolve()
347
+ except ProfileError as exc:
348
+ raise ValueError(str(exc)) from exc
349
+ except Exception:
350
+ profile = None
351
+
339
352
  asyncio.run(
340
353
  run_print_mode(
341
354
  prompt=tui_args.prompt,
355
+ profile=profile,
342
356
  model=resolve_model(tui_args.model) if tui_args.model else None,
343
357
  agent=tui_args.agent,
344
358
  capabilities_dirs=tui_args.capabilities_dirs,