Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
415 changes: 404 additions & 11 deletions src/api/instances.zig

Large diffs are not rendered by default.

985 changes: 985 additions & 0 deletions src/api/meta.zig

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions src/bundled_skills/nullhub-admin/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
name: nullhub-admin
version: 0.1.0
description: Teach managed nullclaw agents to discover NullHub routes first and then use nullhub api for instance, provider, component, and orchestration tasks.
always: true
requires_bins:
- nullhub
---

# NullHub Admin

Use this skill whenever the task involves `nullhub`, NullHub-managed instances, providers, components, or orchestration routes.

Workflow:

1. Do not ask the user for the exact `nullhub` command or endpoint if `nullhub` can discover it.
2. Start with `nullhub routes --json` to discover the current route contract.
3. Use `nullhub api <METHOD> <PATH>` for the actual operation.
4. Prefer a read operation first unless the user already gave a precise destructive intent.
5. After a mutation, verify with a follow-up `GET`.

Rules:

- Prefer `nullhub api` over deleting files directly when NullHub owns the cleanup.
- If a route or payload is unclear, inspect `nullhub routes --json` again instead of guessing or asking the user for syntax.
- Use `--pretty` for user-facing inspection output.
- Use `--body` or `--body-file` for JSON request bodies.
- If path segments come from arbitrary ids or names, percent-encode them before building the request path.
- Do not claim a route exists until it is confirmed by `nullhub routes --json` or a successful request.

Common patterns:

```bash
nullhub routes --json
nullhub api GET /api/meta/routes --pretty
nullhub api GET /api/components --pretty
nullhub api GET /api/instances --pretty
nullhub api GET /api/instances/nullclaw/instance-1 --pretty
nullhub api GET /api/instances/nullclaw/instance-1/skills --pretty
nullhub api DELETE /api/instances/nullclaw/instance-2
nullhub api POST /api/providers/2/validate
```

Shorthand paths are allowed:

```bash
nullhub api GET instances
nullhub api POST providers/2/validate
```
23 changes: 23 additions & 0 deletions src/cli.zig
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub const WizardOptions = struct {
component: []const u8,
};

pub const RoutesOptions = struct {
json: bool = false,
};

pub const ApiOptions = struct {
method: []const u8,
target: []const u8,
Expand Down Expand Up @@ -94,6 +98,7 @@ pub const Command = union(enum) {
update_all,
config: ConfigOptions,
wizard: WizardOptions,
routes: RoutesOptions,
api: ApiOptions,
service: ServiceCommand,
uninstall: UninstallOptions,
Expand Down Expand Up @@ -162,6 +167,9 @@ pub fn parse(args: *std.process.ArgIterator) Command {
if (std.mem.eql(u8, cmd, "wizard")) {
return parseWizard(args);
}
if (std.mem.eql(u8, cmd, "routes")) {
return parseRoutes(args);
}
if (std.mem.eql(u8, cmd, "api")) {
return parseApi(args);
}
Expand Down Expand Up @@ -294,6 +302,18 @@ fn parseService(args: *std.process.ArgIterator) Command {
return .{ .service = sc };
}

fn parseRoutes(args: *std.process.ArgIterator) Command {
var opts = RoutesOptions{};
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--json")) {
opts.json = true;
} else {
return .help;
}
}
return .{ .routes = opts };
}

fn parseApi(args: *std.process.ArgIterator) Command {
const method = args.next() orelse return .help;
const target = args.next() orelse return .help;
Expand Down Expand Up @@ -363,6 +383,7 @@ pub fn printUsage() void {
\\ logs <component/name> View instance logs
\\ config <component/name> View/edit instance config
\\ wizard <component> Run setup wizard
\\ routes [--json] List known nullhub API routes
\\ check-updates Check for updates
\\ update <component/name> Update an instance
\\ update-all Update all instances
Expand All @@ -373,7 +394,9 @@ pub fn printUsage() void {
\\ version, -v, --version Show version
\\
\\API examples:
\\ nullhub routes --json
\\ nullhub api GET /api/instances
\\ nullhub api GET /api/meta/routes --pretty
\\ nullhub api DELETE /api/instances/nullclaw/demo
\\ nullhub api POST providers/2/validate
\\ nullhub api PATCH instances/nullclaw/demo --body '{{"auto_start":true}}'
Expand Down
24 changes: 24 additions & 0 deletions src/installer/orchestrator.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const launch_args_mod = @import("../core/launch_args.zig");
const nullclaw_web_channel = @import("../core/nullclaw_web_channel.zig");
const manager_mod = @import("../supervisor/manager.zig");
const ui_modules_mod = @import("ui_modules.zig");
const managed_skills = @import("../managed_skills.zig");
const MAX_CONFIG_BYTES = 4 * 1024 * 1024;

// ─── Types ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -242,6 +243,29 @@ pub fn install(
return error.ConfigGenerationFailed;
};

if (std.mem.eql(u8, opts.component, "nullclaw")) {
const workspace_dir = std.fs.path.join(allocator, &.{ inst_dir, "workspace" }) catch {
setLastErrorDetail("failed to resolve nullclaw workspace directory");
return error.ConfigGenerationFailed;
};
defer allocator.free(workspace_dir);
const config_path = p.instanceConfig(allocator, opts.component, opts.instance_name) catch {
setLastErrorDetail("failed to resolve nullclaw config path");
return error.ConfigGenerationFailed;
};
defer allocator.free(config_path);

_ = managed_skills.installAlwaysBundledSkills(
allocator,
opts.component,
workspace_dir,
config_path,
) catch {
setLastErrorDetail("failed to seed managed nullclaw skills");
return error.ConfigGenerationFailed;
};
}

// Use the generated config as the source of truth for health checks and
// supervisor state after the component has rendered its final config.
const runtime_port = readConfiguredInstancePort(
Expand Down
2 changes: 2 additions & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const paths_mod = root.paths;
const manager_mod = root.manager;
const access = root.access;
const mdns_mod = root.mdns;
const routes_cli = @import("routes_cli.zig");
const status_cli = root.status_cli;
const version = root.version;

Expand Down Expand Up @@ -62,6 +63,7 @@ pub fn main() !void {
try srv.run();
},
.status => |opts| try status_cli.run(allocator, opts),
.routes => |opts| try routes_cli.run(allocator, opts),
.api => |opts| api_cli.run(allocator, opts) catch |err| {
const any_err: anyerror = err;
switch (any_err) {
Expand Down
Loading
Loading