diff --git a/src/api/instances.zig b/src/api/instances.zig index 6a5fe3b..272763a 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -8,6 +8,7 @@ const local_binary = @import("../core/local_binary.zig"); const component_cli = @import("../core/component_cli.zig"); const integration_mod = @import("../core/integration.zig"); const launch_args_mod = @import("../core/launch_args.zig"); +const managed_skills = @import("../managed_skills.zig"); const manifest_mod = @import("../core/manifest.zig"); const nullclaw_web_channel = @import("../core/nullclaw_web_channel.zig"); @@ -1293,29 +1294,49 @@ fn jsonCliError( return jsonOk(body); } -fn runInstanceCliJson( +fn jsonCliConflict( + allocator: std.mem.Allocator, + code: []const u8, + message: []const u8, + stderr: ?[]const u8, + stdout: ?[]const u8, +) ApiResponse { + const body = buildCliJsonError(allocator, code, message, stderr, stdout) catch return helpers.serverError(); + return .{ + .status = "409 Conflict", + .content_type = "application/json", + .body = body, + }; +} + +const CapturedInstanceCli = union(enum) { + response: ApiResponse, + result: component_cli.RunResult, +}; + +fn runInstanceCliCaptured( allocator: std.mem.Allocator, s: *state_mod.State, paths: paths_mod.Paths, component: []const u8, name: []const u8, args: []const []const u8, -) ApiResponse { - const entry = s.getInstance(component, name) orelse return notFound(); +) CapturedInstanceCli { + const entry = s.getInstance(component, name) orelse return .{ .response = notFound() }; - const bin_path = paths.binary(allocator, component, entry.version) catch return helpers.serverError(); + const bin_path = paths.binary(allocator, component, entry.version) catch return .{ .response = helpers.serverError() }; defer allocator.free(bin_path); std.fs.accessAbsolute(bin_path, .{}) catch { - return jsonCliError( + return .{ .response = jsonCliError( allocator, "component_binary_missing", "Component binary is missing for this instance version", null, null, - ); + ) }; }; - const inst_dir = paths.instanceDir(allocator, component, name) catch return helpers.serverError(); + const inst_dir = paths.instanceDir(allocator, component, name) catch return .{ .response = helpers.serverError() }; defer allocator.free(inst_dir); const result = component_cli.runWithComponentHome( @@ -1326,13 +1347,30 @@ fn runInstanceCliJson( null, inst_dir, ) catch { - return jsonCliError( + return .{ .response = jsonCliError( allocator, "cli_exec_failed", "Failed to execute component CLI", null, null, - ); + ) }; + }; + + return .{ .result = result }; +} + +fn runInstanceCliJson( + allocator: std.mem.Allocator, + s: *state_mod.State, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + args: []const []const u8, +) ApiResponse { + const captured = runInstanceCliCaptured(allocator, s, paths, component, name, args); + const result = switch (captured) { + .response => |resp| return resp, + .result => |value| value, }; defer allocator.free(result.stderr); @@ -1456,6 +1494,14 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * name, ) catch return helpers.serverError(); + if (std.mem.eql(u8, component, "nullclaw")) { + const workspace_dir = instanceWorkspaceDir(allocator, paths, component, name) catch return helpers.serverError(); + defer allocator.free(workspace_dir); + const config_path = paths.instanceConfig(allocator, component, name) catch return helpers.serverError(); + defer allocator.free(config_path); + _ = managed_skills.installAlwaysBundledSkills(allocator, component, workspace_dir, config_path) catch return helpers.serverError(); + } + // Check if body overrides startup settings. const StartBody = struct { launch_mode: ?[]const u8 = null, @@ -1984,9 +2030,232 @@ pub fn handleMemory(allocator: std.mem.Allocator, s: *state_mod.State, paths: pa return runInstanceCliJson(allocator, s, paths, component, name, args.items); } +fn instanceWorkspaceDir(allocator: std.mem.Allocator, paths: paths_mod.Paths, component: []const u8, name: []const u8) ![]u8 { + const inst_dir = try paths.instanceDir(allocator, component, name); + defer allocator.free(inst_dir); + return try std.fs.path.join(allocator, &.{ inst_dir, "workspace" }); +} + +fn handleSkillsCatalog(allocator: std.mem.Allocator, component: []const u8) ApiResponse { + const bundled = managed_skills.catalogForComponent(component); + var entries = std.array_list.Managed(managed_skills.CatalogEntry).init(allocator); + defer entries.deinit(); + for (bundled) |skill| { + entries.append(skill.entry) catch return helpers.serverError(); + } + const body = std.json.Stringify.valueAlloc(allocator, entries.items, .{ + .emit_null_optional_fields = false, + }) catch return helpers.serverError(); + return jsonOk(body); +} + +fn handleSkillsInstall( + allocator: std.mem.Allocator, + s: *state_mod.State, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + body: []const u8, +) ApiResponse { + _ = s.getInstance(component, name) orelse return notFound(); + if (!std.mem.eql(u8, component, "nullclaw")) { + return badRequest("{\"error\":\"skill installation is only supported for nullclaw instances\"}"); + } + + const parsed = std.json.parseFromSlice(struct { + bundled: ?[]const u8 = null, + clawhub_slug: ?[]const u8 = null, + source: ?[]const u8 = null, + }, allocator, body, .{ + .ignore_unknown_fields = true, + }) catch return badRequest("{\"error\":\"invalid JSON body\"}"); + defer parsed.deinit(); + + const bundled_name = if (parsed.value.bundled) |value| if (value.len > 0) value else null else null; + const clawhub_slug = if (parsed.value.clawhub_slug) |value| if (value.len > 0) value else null else null; + const source = if (parsed.value.source) |value| if (value.len > 0) value else null else null; + + var selected: usize = 0; + if (bundled_name != null) selected += 1; + if (clawhub_slug != null) selected += 1; + if (source != null) selected += 1; + if (selected != 1) { + return badRequest("{\"error\":\"provide exactly one of bundled, clawhub_slug, or source\"}"); + } + + if (bundled_name) |value| { + const workspace_dir = instanceWorkspaceDir(allocator, paths, component, name) catch return helpers.serverError(); + defer allocator.free(workspace_dir); + const disposition = managed_skills.installBundledSkill(allocator, workspace_dir, value) catch |err| switch (err) { + error.SkillNotFound => return notFound(), + else => return helpers.serverError(), + }; + const config_path = paths.instanceConfig(allocator, component, name) catch return helpers.serverError(); + defer allocator.free(config_path); + const restart_required = managed_skills.syncBundledSkillRuntime(allocator, config_path, value) catch |err| switch (err) { + error.SkillNotFound => return notFound(), + else => return helpers.serverError(), + }; + const resp_body = std.json.Stringify.valueAlloc(allocator, .{ + .status = @tagName(disposition), + .bundled = value, + .restart_required = restart_required, + }, .{}) catch return helpers.serverError(); + return jsonOk(resp_body); + } + + if (clawhub_slug) |value| { + const workspace_dir = instanceWorkspaceDir(allocator, paths, component, name) catch return helpers.serverError(); + defer allocator.free(workspace_dir); + + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "clawhub", "install", value }, + .cwd = workspace_dir, + .max_output_bytes = 64 * 1024, + }) catch |err| switch (err) { + error.FileNotFound => return jsonCliConflict( + allocator, + "clawhub_not_available", + "clawhub CLI is not installed on the nullhub host", + null, + null, + ), + else => return jsonCliConflict( + allocator, + "clawhub_exec_failed", + "Failed to execute clawhub install", + null, + null, + ), + }; + defer { + allocator.free(result.stdout); + allocator.free(result.stderr); + } + + const success = switch (result.term) { + .Exited => |code| code == 0, + else => false, + }; + if (!success) { + return jsonCliConflict( + allocator, + "clawhub_install_failed", + "clawhub install failed", + result.stderr, + result.stdout, + ); + } + + const resp_body = std.json.Stringify.valueAlloc(allocator, .{ + .status = "installed", + .clawhub_slug = value, + }, .{}) catch return helpers.serverError(); + return jsonOk(resp_body); + } + + var args: std.ArrayListUnmanaged([]const u8) = .empty; + defer args.deinit(allocator); + args.append(allocator, "skills") catch return helpers.serverError(); + args.append(allocator, "install") catch return helpers.serverError(); + args.append(allocator, source.?) catch return helpers.serverError(); + + const captured = runInstanceCliCaptured(allocator, s, paths, component, name, args.items); + const result = switch (captured) { + .response => |resp| { + if (std.mem.eql(u8, resp.status, "200 OK")) { + return .{ + .status = "409 Conflict", + .content_type = resp.content_type, + .body = resp.body, + }; + } + return resp; + }, + .result => |value| value, + }; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + if (!result.success) { + return jsonCliConflict( + allocator, + "skills_install_failed", + "Failed to install skill from source", + result.stderr, + result.stdout, + ); + } + + const resp_body = std.json.Stringify.valueAlloc(allocator, .{ + .status = "installed", + .source = source.?, + }, .{}) catch return helpers.serverError(); + return jsonOk(resp_body); +} + +fn handleSkillsRemove( + allocator: std.mem.Allocator, + s: *state_mod.State, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + target: []const u8, +) ApiResponse { + if (!std.mem.eql(u8, component, "nullclaw")) { + return badRequest("{\"error\":\"skill removal is only supported for nullclaw instances\"}"); + } + const skill_name = queryParamValueAlloc(allocator, target, "name") catch return helpers.serverError(); + defer if (skill_name) |value| allocator.free(value); + if (skill_name == null or skill_name.?.len == 0) { + return badRequest("{\"error\":\"name is required\"}"); + } + + var args: std.ArrayListUnmanaged([]const u8) = .empty; + defer args.deinit(allocator); + args.append(allocator, "skills") catch return helpers.serverError(); + args.append(allocator, "remove") catch return helpers.serverError(); + args.append(allocator, skill_name.?) catch return helpers.serverError(); + + const captured = runInstanceCliCaptured(allocator, s, paths, component, name, args.items); + const result = switch (captured) { + .response => |resp| { + if (std.mem.eql(u8, resp.status, "200 OK")) { + return .{ + .status = "409 Conflict", + .content_type = resp.content_type, + .body = resp.body, + }; + } + return resp; + }, + .result => |value| value, + }; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + if (!result.success) { + return jsonCliConflict( + allocator, + "skills_remove_failed", + "Failed to remove skill", + result.stderr, + result.stdout, + ); + } + + const resp_body = std.json.Stringify.valueAlloc(allocator, .{ + .status = "removed", + .name = skill_name.?, + }, .{}) catch return helpers.serverError(); + return jsonOk(resp_body); +} + /// GET /api/instances/{component}/{name}/skills /// GET /api/instances/{component}/{name}/skills?name=... +/// GET /api/instances/{component}/{name}/skills?catalog=1 pub fn handleSkills(allocator: std.mem.Allocator, s: *state_mod.State, paths: paths_mod.Paths, component: []const u8, name: []const u8, target: []const u8) ApiResponse { + _ = s.getInstance(component, name) orelse return notFound(); + if (queryParamBool(target, "catalog")) return handleSkillsCatalog(allocator, component); const skill_name = queryParamValueAlloc(allocator, target, "name") catch return helpers.serverError(); defer if (skill_name) |value| allocator.free(value); @@ -2588,8 +2857,10 @@ pub fn dispatch( return handleMemory(allocator, s, paths, parsed.component, parsed.name, target); } if (std.mem.eql(u8, action, "skills")) { - if (!std.mem.eql(u8, method, "GET")) return methodNotAllowed(); - return handleSkills(allocator, s, paths, parsed.component, parsed.name, target); + if (std.mem.eql(u8, method, "GET")) return handleSkills(allocator, s, paths, parsed.component, parsed.name, target); + if (std.mem.eql(u8, method, "POST")) return handleSkillsInstall(allocator, s, paths, parsed.component, parsed.name, body); + if (std.mem.eql(u8, method, "DELETE")) return handleSkillsRemove(allocator, s, paths, parsed.component, parsed.name, target); + return methodNotAllowed(); } if (std.mem.eql(u8, action, "integration")) { if (std.mem.eql(u8, method, "GET")) return handleIntegrationGet(allocator, s, manager, mutex, paths, parsed.component, parsed.name); @@ -3809,6 +4080,128 @@ test "dispatch routes GET skills action" { try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"name\":\"checks\"") != null); } +test "dispatch routes GET skills catalog" { + const allocator = std.testing.allocator; + var s = state_mod.State.init(allocator, "/tmp/nullhub-test-instances-api.json"); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.2" }); + + const resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullclaw/my-agent/skills?catalog=1", "").?; + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"name\":\"nullhub-admin\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"install_kind\":\"bundled\"") != null); +} + +test "dispatch routes POST bundled skill install" { + const allocator = std.testing.allocator; + var s = state_mod.State.init(allocator, "/tmp/nullhub-test-instances-api.json"); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + std.fs.deleteTreeAbsolute(mctx.paths.root) catch {}; + defer std.fs.deleteTreeAbsolute(mctx.paths.root) catch {}; + + try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.2" }); + try writeTestInstanceConfig(allocator, mctx.paths, "nullclaw", "my-agent", "{\"autonomy\":{\"level\":\"supervised\"}}"); + + const resp = dispatch( + allocator, + &s, + &mctx.manager, + &mctx.mutex, + mctx.paths, + "POST", + "/api/instances/nullclaw/my-agent/skills", + "{\"bundled\":\"nullhub-admin\"}", + ).?; + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"bundled\":\"nullhub-admin\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"restart_required\":true") != null); + + const inst_dir = try mctx.paths.instanceDir(allocator, "nullclaw", "my-agent"); + defer allocator.free(inst_dir); + const skill_path = try std.fs.path.join(allocator, &.{ inst_dir, "workspace", "skills", "nullhub-admin", "SKILL.md" }); + defer allocator.free(skill_path); + const installed = std.fs.readFileAbsolute(allocator, skill_path, 64 * 1024) catch @panic("missing skill"); + defer allocator.free(installed); + try std.testing.expect(std.mem.indexOf(u8, installed, "nullhub api ") != null); + + const config_path = try mctx.paths.instanceConfig(allocator, "nullclaw", "my-agent"); + defer allocator.free(config_path); + const config = try std.fs.readFileAbsolute(allocator, config_path, 64 * 1024); + defer allocator.free(config); + try std.testing.expect(std.mem.indexOf(u8, config, "\"nullhub *\"") != null); +} + +test "dispatch routes DELETE skills action" { + const allocator = std.testing.allocator; + var s = state_mod.State.init(allocator, "/tmp/nullhub-test-instances-api.json"); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + std.fs.deleteTreeAbsolute(mctx.paths.root) catch {}; + defer std.fs.deleteTreeAbsolute(mctx.paths.root) catch {}; + + try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.3" }); + const script = + \\#!/bin/sh + \\if [ "$1" = "skills" ] && [ "$2" = "remove" ] && [ "$3" = "nullhub-admin" ]; then + \\ printf '%s\n' 'Removed skill: nullhub-admin' + \\ exit 0 + \\fi + \\echo "unexpected args" >&2 + \\exit 1 + \\ + ; + try writeTestBinary(allocator, mctx.paths, "nullclaw", "1.0.3", script); + + const resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "DELETE", "/api/instances/nullclaw/my-agent/skills?name=nullhub-admin", "").?; + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"status\":\"removed\"") != null); +} + +test "dispatch routes POST source install returns conflict on CLI failure" { + const allocator = std.testing.allocator; + var s = state_mod.State.init(allocator, "/tmp/nullhub-test-instances-api.json"); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + std.fs.deleteTreeAbsolute(mctx.paths.root) catch {}; + defer std.fs.deleteTreeAbsolute(mctx.paths.root) catch {}; + + try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.4" }); + const script = + \\#!/bin/sh + \\echo "network blocked" >&2 + \\exit 1 + \\ + ; + try writeTestBinary(allocator, mctx.paths, "nullclaw", "1.0.4", script); + + const resp = dispatch( + allocator, + &s, + &mctx.manager, + &mctx.mutex, + mctx.paths, + "POST", + "/api/instances/nullclaw/my-agent/skills", + "{\"source\":\"https://example.com/skill.git\"}", + ).?; + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("409 Conflict", resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"error\":\"skills_install_failed\"") != null); +} + test "dispatch returns null for non-matching path" { const allocator = std.testing.allocator; var s = state_mod.State.init(allocator, "/tmp/nullhub-test-instances-api.json"); diff --git a/src/api/meta.zig b/src/api/meta.zig new file mode 100644 index 0000000..166dccd --- /dev/null +++ b/src/api/meta.zig @@ -0,0 +1,985 @@ +const std = @import("std"); +const helpers = @import("helpers.zig"); + +pub const ParamSpec = struct { + name: []const u8, + location: []const u8, + required: bool, + description: []const u8, +}; + +pub const ExampleSpec = struct { + command: []const u8, + description: []const u8, +}; + +pub const RouteSpec = struct { + id: []const u8, + method: []const u8, + path_template: []const u8, + category: []const u8, + summary: []const u8, + destructive: bool = false, + auth_required: bool = false, + auth_mode: []const u8 = "optional_bearer", + path_params: []const ParamSpec = &.{}, + query_params: []const ParamSpec = &.{}, + body: ?[]const u8 = null, + response: ?[]const u8 = null, + examples: []const ExampleSpec = &.{}, +}; + +const Document = struct { + version: u32, + routes: []const RouteSpec, +}; + +const component_param = ParamSpec{ + .name = "component", + .location = "path", + .required = true, + .description = "Component name such as nullclaw, nullhub, nullboiler, or nulltickets.", +}; + +const instance_name_param = ParamSpec{ + .name = "name", + .location = "path", + .required = true, + .description = "Instance name within the component namespace.", +}; + +const module_name_param = ParamSpec{ + .name = "module", + .location = "path", + .required = true, + .description = "UI module name.", +}; + +const component_name_param = ParamSpec{ + .name = "name", + .location = "path", + .required = true, + .description = "Component name.", +}; + +const wizard_component_param = ParamSpec{ + .name = "component", + .location = "path", + .required = true, + .description = "Component to inspect or configure through the setup wizard.", +}; + +const provider_id_param = ParamSpec{ + .name = "id", + .location = "path", + .required = true, + .description = "Saved provider numeric identifier.", +}; + +const channel_id_param = ParamSpec{ + .name = "id", + .location = "path", + .required = true, + .description = "Saved channel numeric identifier.", +}; + +const window_query = ParamSpec{ + .name = "window", + .location = "query", + .required = false, + .description = "Usage window such as 24h, 7d, 30d, or all.", +}; + +const reveal_query = ParamSpec{ + .name = "reveal", + .location = "query", + .required = false, + .description = "When true, include secret-like fields in the response for local admin usage.", +}; + +const lines_query = ParamSpec{ + .name = "lines", + .location = "query", + .required = false, + .description = "How many log lines to return. Defaults to 100.", +}; + +const log_source_query = ParamSpec{ + .name = "source", + .location = "query", + .required = false, + .description = "Log source selector: instance or nullhub.", +}; + +const history_limit_query = ParamSpec{ + .name = "limit", + .location = "query", + .required = false, + .description = "Maximum number of history entries to return.", +}; + +const history_offset_query = ParamSpec{ + .name = "offset", + .location = "query", + .required = false, + .description = "History pagination offset.", +}; + +const history_session_query = ParamSpec{ + .name = "session_id", + .location = "query", + .required = false, + .description = "Optional nullclaw session identifier to scope history.", +}; + +const memory_stats_query = ParamSpec{ + .name = "stats", + .location = "query", + .required = false, + .description = "When set, returns memory stats instead of entries.", +}; + +const memory_key_query = ParamSpec{ + .name = "key", + .location = "query", + .required = false, + .description = "Fetch a single memory record by key.", +}; + +const memory_query_query = ParamSpec{ + .name = "query", + .location = "query", + .required = false, + .description = "Keyword search query for instance memory.", +}; + +const memory_category_query = ParamSpec{ + .name = "category", + .location = "query", + .required = false, + .description = "Category filter for memory listing.", +}; + +const memory_limit_query = ParamSpec{ + .name = "limit", + .location = "query", + .required = false, + .description = "Maximum number of memory results.", +}; + +const skill_name_query = ParamSpec{ + .name = "name", + .location = "query", + .required = false, + .description = "Optional skill name filter.", +}; + +const skill_catalog_query = ParamSpec{ + .name = "catalog", + .location = "query", + .required = false, + .description = "When true, return the recommended skill catalog instead of installed skills.", +}; + +const common_instance_params = [_]ParamSpec{ component_param, instance_name_param }; +const component_only_params = [_]ParamSpec{component_param}; +const provider_id_params = [_]ParamSpec{provider_id_param}; +const channel_id_params = [_]ParamSpec{channel_id_param}; +const module_name_params = [_]ParamSpec{module_name_param}; +const component_name_params = [_]ParamSpec{component_name_param}; +const wizard_component_params = [_]ParamSpec{wizard_component_param}; +const usage_query_params = [_]ParamSpec{window_query}; +const reveal_query_params = [_]ParamSpec{reveal_query}; +const logs_query_params = [_]ParamSpec{ lines_query, log_source_query }; +const history_query_params = [_]ParamSpec{ history_session_query, history_limit_query, history_offset_query }; +const memory_query_params = [_]ParamSpec{ memory_stats_query, memory_key_query, memory_query_query, memory_category_query, memory_limit_query }; +const skills_query_params = [_]ParamSpec{ skill_name_query, skill_catalog_query }; + +const route_examples_status = [_]ExampleSpec{ + .{ + .command = "nullhub api GET /api/status --pretty", + .description = "Inspect hub health, uptime, and instance summary.", + }, +}; + +const route_examples_instances = [_]ExampleSpec{ + .{ + .command = "nullhub api GET /api/instances --pretty", + .description = "List all managed instances.", + }, +}; + +const route_examples_delete_instance = [_]ExampleSpec{ + .{ + .command = "nullhub api DELETE /api/instances/nullclaw/instance-2", + .description = "Delete a managed nullclaw instance and let nullhub clean related state.", + }, +}; + +const route_examples_provider_validate = [_]ExampleSpec{ + .{ + .command = "nullhub api POST /api/providers/2/validate", + .description = "Run a live provider credential probe.", + }, +}; + +const route_examples_skill_catalog = [_]ExampleSpec{ + .{ + .command = "nullhub api GET '/api/instances/nullclaw/instance-1/skills?catalog=1' --pretty", + .description = "Inspect the recommended skill catalog for a managed nullclaw instance.", + }, +}; + +const route_examples_skill_install = [_]ExampleSpec{ + .{ + .command = "nullhub api POST /api/instances/nullclaw/instance-1/skills --body '{\"bundled\":\"nullhub-admin\"}'", + .description = "Install the bundled nullhub-admin skill into a managed nullclaw workspace.", + }, + .{ + .command = "nullhub api POST /api/instances/nullclaw/instance-1/skills --body '{\"clawhub_slug\":\"my-skill\"}'", + .description = "Install a skill from ClawHub when the host has the clawhub CLI available.", + }, +}; + +const route_examples_skill_remove = [_]ExampleSpec{ + .{ + .command = "nullhub api DELETE '/api/instances/nullclaw/instance-1/skills?name=nullhub-admin'", + .description = "Remove a workspace-installed skill from a managed nullclaw instance.", + }, +}; + +const route_examples_meta = [_]ExampleSpec{ + .{ + .command = "nullhub routes --json", + .description = "Inspect the machine-readable route catalog locally without a running server.", + }, + .{ + .command = "nullhub api GET /api/meta/routes --pretty", + .description = "Fetch the same route catalog over HTTP.", + }, +}; + +const routes = [_]RouteSpec{ + .{ + .id = "health", + .method = "GET", + .path_template = "/health", + .category = "meta", + .summary = "Lightweight liveness probe for load balancers and local checks.", + .auth_mode = "public", + .response = "Returns {\"status\":\"ok\"}.", + }, + .{ + .id = "status.get", + .method = "GET", + .path_template = "/api/status", + .category = "meta", + .summary = "Hub status, access URLs, and live instance overview.", + .auth_mode = "optional_bearer", + .response = "Aggregated status document used by the dashboard.", + .examples = route_examples_status[0..], + }, + .{ + .id = "meta.routes.get", + .method = "GET", + .path_template = "/api/meta/routes", + .category = "meta", + .summary = "Machine-readable catalog of stable nullhub HTTP routes.", + .auth_mode = "optional_bearer", + .response = "JSON document with route ids, methods, paths, parameters, and examples.", + .examples = route_examples_meta[0..], + }, + .{ + .id = "components.list", + .method = "GET", + .path_template = "/api/components", + .category = "components", + .summary = "List known ecosystem components and installation state.", + .auth_mode = "optional_bearer", + .response = "Component array with installed/version metadata.", + }, + .{ + .id = "components.manifest.get", + .method = "GET", + .path_template = "/api/components/{name}/manifest", + .category = "components", + .summary = "Return cached component manifest JSON if available.", + .auth_mode = "optional_bearer", + .path_params = component_name_params[0..], + .response = "Manifest JSON exported by the component binary.", + }, + .{ + .id = "components.refresh", + .method = "POST", + .path_template = "/api/components/refresh", + .category = "components", + .summary = "Refresh the component registry and cached manifests.", + .auth_mode = "optional_bearer", + .response = "Refresh status payload.", + }, + .{ + .id = "wizard.free_port", + .method = "GET", + .path_template = "/api/free-port", + .category = "wizard", + .summary = "Find an available local TCP port during setup flows.", + .auth_mode = "optional_bearer", + .response = "Returns {\"port\":}.", + }, + .{ + .id = "usage.global.get", + .method = "GET", + .path_template = "/api/usage", + .category = "usage", + .summary = "Aggregate usage across the whole hub.", + .auth_mode = "optional_bearer", + .query_params = usage_query_params[0..], + .response = "Cross-instance usage summary.", + }, + .{ + .id = "settings.get", + .method = "GET", + .path_template = "/api/settings", + .category = "settings", + .summary = "Read hub settings and published access URLs.", + .auth_mode = "optional_bearer", + .response = "Current nullhub settings document.", + }, + .{ + .id = "settings.put", + .method = "PUT", + .path_template = "/api/settings", + .category = "settings", + .summary = "Update hub settings such as port or access behavior.", + .auth_mode = "optional_bearer", + .body = "Settings JSON payload.", + .response = "Saved settings payload.", + }, + .{ + .id = "service.install", + .method = "POST", + .path_template = "/api/service/install", + .category = "settings", + .summary = "Install nullhub as an OS service.", + .auth_mode = "optional_bearer", + .response = "Platform-specific install result.", + }, + .{ + .id = "service.uninstall", + .method = "POST", + .path_template = "/api/service/uninstall", + .category = "settings", + .summary = "Remove the OS service installation for nullhub.", + .auth_mode = "optional_bearer", + .destructive = true, + .response = "Service uninstall result.", + }, + .{ + .id = "service.status", + .method = "GET", + .path_template = "/api/service/status", + .category = "settings", + .summary = "Inspect whether the OS service is installed and running.", + .auth_mode = "optional_bearer", + .response = "Service status payload.", + }, + .{ + .id = "updates.list", + .method = "GET", + .path_template = "/api/updates", + .category = "updates", + .summary = "List available component updates.", + .auth_mode = "optional_bearer", + .response = "Pending update list.", + }, + .{ + .id = "ui_modules.list", + .method = "GET", + .path_template = "/api/ui-modules", + .category = "ui", + .summary = "List installed UI modules and selected versions.", + .auth_mode = "optional_bearer", + .response = "Map of UI module names to selected versions.", + }, + .{ + .id = "ui_modules.available", + .method = "GET", + .path_template = "/api/ui-modules/available", + .category = "ui", + .summary = "List UI modules available from known component sources.", + .auth_mode = "optional_bearer", + .response = "Available UI module records.", + }, + .{ + .id = "ui_modules.install", + .method = "POST", + .path_template = "/api/ui-modules/{module}/install", + .category = "ui", + .summary = "Install or refresh a UI module.", + .auth_mode = "optional_bearer", + .path_params = module_name_params[0..], + .response = "Install status payload.", + }, + .{ + .id = "ui_modules.delete", + .method = "DELETE", + .path_template = "/api/ui-modules/{module}", + .category = "ui", + .summary = "Uninstall a UI module.", + .auth_mode = "optional_bearer", + .path_params = module_name_params[0..], + .destructive = true, + .response = "Delete status payload.", + }, + .{ + .id = "wizard.get", + .method = "GET", + .path_template = "/api/wizard/{component}", + .category = "wizard", + .summary = "Fetch wizard metadata and defaults for a component.", + .auth_mode = "optional_bearer", + .path_params = wizard_component_params[0..], + .response = "Wizard definition JSON.", + }, + .{ + .id = "wizard.post", + .method = "POST", + .path_template = "/api/wizard/{component}", + .category = "wizard", + .summary = "Create or update a component instance from wizard form data.", + .auth_mode = "optional_bearer", + .path_params = wizard_component_params[0..], + .body = "Wizard submission JSON.", + .response = "Created instance payload or validation error.", + }, + .{ + .id = "wizard.versions.get", + .method = "GET", + .path_template = "/api/wizard/{component}/versions", + .category = "wizard", + .summary = "List installable versions for a component.", + .auth_mode = "optional_bearer", + .path_params = wizard_component_params[0..], + .response = "Version options for installer flows.", + }, + .{ + .id = "wizard.models.get", + .method = "GET", + .path_template = "/api/wizard/{component}/models", + .category = "wizard", + .summary = "List model options for a component/provider pairing.", + .auth_mode = "optional_bearer", + .path_params = wizard_component_params[0..], + .response = "Model list payload.", + }, + .{ + .id = "wizard.models.post", + .method = "POST", + .path_template = "/api/wizard/{component}/models", + .category = "wizard", + .summary = "Resolve model options from posted credentials or provider selection.", + .auth_mode = "optional_bearer", + .path_params = wizard_component_params[0..], + .body = "Provider/model discovery request JSON.", + .response = "Model list payload or validation error.", + }, + .{ + .id = "wizard.validate_providers", + .method = "POST", + .path_template = "/api/wizard/{component}/validate-providers", + .category = "wizard", + .summary = "Validate provider credentials during setup.", + .auth_mode = "optional_bearer", + .path_params = wizard_component_params[0..], + .body = "Provider validation request JSON.", + .response = "Validation result array.", + }, + .{ + .id = "wizard.validate_channels", + .method = "POST", + .path_template = "/api/wizard/{component}/validate-channels", + .category = "wizard", + .summary = "Validate channel credentials during setup.", + .auth_mode = "optional_bearer", + .path_params = wizard_component_params[0..], + .body = "Channel validation request JSON.", + .response = "Validation result array.", + }, + .{ + .id = "providers.list", + .method = "GET", + .path_template = "/api/providers", + .category = "providers", + .summary = "List saved providers.", + .auth_mode = "optional_bearer", + .query_params = reveal_query_params[0..], + .response = "Saved provider list.", + }, + .{ + .id = "providers.create", + .method = "POST", + .path_template = "/api/providers", + .category = "providers", + .summary = "Create a saved provider entry.", + .auth_mode = "optional_bearer", + .body = "Provider create payload.", + .response = "Created provider record.", + }, + .{ + .id = "providers.update", + .method = "PUT", + .path_template = "/api/providers/{id}", + .category = "providers", + .summary = "Update a saved provider entry.", + .auth_mode = "optional_bearer", + .path_params = provider_id_params[0..], + .body = "Provider update payload.", + .response = "Updated provider record.", + }, + .{ + .id = "providers.delete", + .method = "DELETE", + .path_template = "/api/providers/{id}", + .category = "providers", + .summary = "Delete a saved provider entry.", + .auth_mode = "optional_bearer", + .path_params = provider_id_params[0..], + .destructive = true, + .response = "Delete status payload.", + }, + .{ + .id = "providers.validate", + .method = "POST", + .path_template = "/api/providers/{id}/validate", + .category = "providers", + .summary = "Run a live provider probe using the saved config.", + .auth_mode = "optional_bearer", + .path_params = provider_id_params[0..], + .response = "Provider validation result.", + .examples = route_examples_provider_validate[0..], + }, + .{ + .id = "channels.list", + .method = "GET", + .path_template = "/api/channels", + .category = "channels", + .summary = "List saved channels.", + .auth_mode = "optional_bearer", + .query_params = reveal_query_params[0..], + .response = "Saved channel list.", + }, + .{ + .id = "channels.create", + .method = "POST", + .path_template = "/api/channels", + .category = "channels", + .summary = "Create a saved channel entry.", + .auth_mode = "optional_bearer", + .body = "Channel create payload.", + .response = "Created channel record.", + }, + .{ + .id = "channels.update", + .method = "PUT", + .path_template = "/api/channels/{id}", + .category = "channels", + .summary = "Update a saved channel entry.", + .auth_mode = "optional_bearer", + .path_params = channel_id_params[0..], + .body = "Channel update payload.", + .response = "Updated channel record.", + }, + .{ + .id = "channels.delete", + .method = "DELETE", + .path_template = "/api/channels/{id}", + .category = "channels", + .summary = "Delete a saved channel entry.", + .auth_mode = "optional_bearer", + .path_params = channel_id_params[0..], + .destructive = true, + .response = "Delete status payload.", + }, + .{ + .id = "channels.validate", + .method = "POST", + .path_template = "/api/channels/{id}/validate", + .category = "channels", + .summary = "Run a live channel probe using the saved config.", + .auth_mode = "optional_bearer", + .path_params = channel_id_params[0..], + .response = "Channel validation result.", + }, + .{ + .id = "instances.list", + .method = "GET", + .path_template = "/api/instances", + .category = "instances", + .summary = "List all managed instances across components.", + .auth_mode = "optional_bearer", + .response = "Instance collection grouped by component.", + .examples = route_examples_instances[0..], + }, + .{ + .id = "instances.get", + .method = "GET", + .path_template = "/api/instances/{component}/{name}", + .category = "instances", + .summary = "Read a single instance detail record.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .response = "Instance detail payload.", + }, + .{ + .id = "instances.patch", + .method = "PATCH", + .path_template = "/api/instances/{component}/{name}", + .category = "instances", + .summary = "Update instance launch metadata such as auto_start or verbose mode.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .body = "Partial instance settings JSON.", + .response = "Updated instance status payload.", + }, + .{ + .id = "instances.delete", + .method = "DELETE", + .path_template = "/api/instances/{component}/{name}", + .category = "instances", + .summary = "Delete an instance and let nullhub clean its managed files.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .destructive = true, + .response = "Delete status payload.", + .examples = route_examples_delete_instance[0..], + }, + .{ + .id = "instances.start", + .method = "POST", + .path_template = "/api/instances/{component}/{name}/start", + .category = "instances", + .summary = "Start an instance process.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .body = "Optional launch overrides such as launch_mode or verbose.", + .response = "Start status payload.", + }, + .{ + .id = "instances.stop", + .method = "POST", + .path_template = "/api/instances/{component}/{name}/stop", + .category = "instances", + .summary = "Stop an instance process.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .response = "Stop status payload.", + }, + .{ + .id = "instances.restart", + .method = "POST", + .path_template = "/api/instances/{component}/{name}/restart", + .category = "instances", + .summary = "Restart an instance process.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .body = "Optional launch overrides such as launch_mode or verbose.", + .response = "Restart status payload.", + }, + .{ + .id = "instances.provider_health", + .method = "GET", + .path_template = "/api/instances/{component}/{name}/provider-health", + .category = "instances", + .summary = "Probe the live provider config of an instance.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .response = "Provider probe result.", + }, + .{ + .id = "instances.usage", + .method = "GET", + .path_template = "/api/instances/{component}/{name}/usage", + .category = "instances", + .summary = "Read per-instance usage aggregates.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .query_params = usage_query_params[0..], + .response = "Instance usage payload.", + }, + .{ + .id = "instances.history", + .method = "GET", + .path_template = "/api/instances/{component}/{name}/history", + .category = "instances", + .summary = "Read persisted conversation history for an instance.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .query_params = history_query_params[0..], + .response = "Paginated history payload.", + }, + .{ + .id = "instances.onboarding", + .method = "GET", + .path_template = "/api/instances/{component}/{name}/onboarding", + .category = "instances", + .summary = "Read onboarding/bootstrap status for an instance.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .response = "Onboarding status payload.", + }, + .{ + .id = "instances.memory", + .method = "GET", + .path_template = "/api/instances/{component}/{name}/memory", + .category = "instances", + .summary = "Inspect instance memory stats, records, or searches.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .query_params = memory_query_params[0..], + .response = "Memory stats or memory entry list depending on query mode.", + }, + .{ + .id = "instances.skills", + .method = "GET", + .path_template = "/api/instances/{component}/{name}/skills", + .category = "instances", + .summary = "List installed skills for an instance.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .query_params = skills_query_params[0..], + .response = "Skill list or single skill detail.", + }, + .{ + .id = "instances.skills.catalog", + .method = "GET", + .path_template = "/api/instances/{component}/{name}/skills?catalog=1", + .category = "instances", + .summary = "List recommended managed skills for the instance component.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .query_params = skills_query_params[0..], + .response = "Recommended skill catalog entries.", + .examples = route_examples_skill_catalog[0..], + }, + .{ + .id = "instances.skills.install", + .method = "POST", + .path_template = "/api/instances/{component}/{name}/skills", + .category = "instances", + .summary = "Install a skill into a managed nullclaw workspace from a bundled skill, ClawHub slug, or source URL/path.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .body = "JSON body with exactly one of bundled, clawhub_slug, or source.", + .response = "Install result payload.", + .examples = route_examples_skill_install[0..], + }, + .{ + .id = "instances.skills.remove", + .method = "DELETE", + .path_template = "/api/instances/{component}/{name}/skills", + .category = "instances", + .summary = "Remove a workspace-installed skill from a managed nullclaw instance.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .query_params = skills_query_params[0..], + .body = null, + .response = "Remove result payload.", + .examples = route_examples_skill_remove[0..], + }, + .{ + .id = "instances.integration.get", + .method = "GET", + .path_template = "/api/instances/{component}/{name}/integration", + .category = "instances", + .summary = "Read integration status for linked orchestration and tracker components.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .response = "Integration status and linkage payload.", + }, + .{ + .id = "instances.integration.post", + .method = "POST", + .path_template = "/api/instances/{component}/{name}/integration", + .category = "instances", + .summary = "Link or relink supported components such as nullboiler and nulltickets.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .body = "Integration update payload.", + .response = "Integration update result.", + }, + .{ + .id = "instances.import", + .method = "POST", + .path_template = "/api/instances/{component}/import", + .category = "instances", + .summary = "Import a standalone installation into nullhub management.", + .auth_mode = "optional_bearer", + .path_params = component_only_params[0..], + .response = "Imported instance payload.", + }, + .{ + .id = "instances.config.get", + .method = "GET", + .path_template = "/api/instances/{component}/{name}/config", + .category = "instances", + .summary = "Read the raw instance config.json managed by nullhub.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .response = "Raw instance config JSON.", + }, + .{ + .id = "instances.config.put", + .method = "PUT", + .path_template = "/api/instances/{component}/{name}/config", + .category = "instances", + .summary = "Replace the raw instance config.json managed by nullhub.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .body = "Complete config.json replacement body.", + .response = "Save status payload.", + }, + .{ + .id = "instances.config.patch", + .method = "PATCH", + .path_template = "/api/instances/{component}/{name}/config", + .category = "instances", + .summary = "Patch the raw instance config.json. Currently treated the same as PUT.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .body = "Complete config.json replacement body.", + .response = "Save status payload.", + }, + .{ + .id = "instances.logs.get", + .method = "GET", + .path_template = "/api/instances/{component}/{name}/logs", + .category = "instances", + .summary = "Read the log tail for an instance or its nullhub supervisor log.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .query_params = logs_query_params[0..], + .response = "Log tail payload.", + }, + .{ + .id = "instances.logs.delete", + .method = "DELETE", + .path_template = "/api/instances/{component}/{name}/logs", + .category = "instances", + .summary = "Clear stored log files for an instance or its nullhub supervisor log.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .query_params = logs_query_params[0..], + .destructive = true, + .response = "Delete status payload.", + }, + .{ + .id = "instances.logs.stream", + .method = "GET", + .path_template = "/api/instances/{component}/{name}/logs/stream", + .category = "instances", + .summary = "Snapshot current log tail in a stream-shaped response.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .query_params = logs_query_params[0..], + .response = "Log stream payload.", + }, + .{ + .id = "instances.update", + .method = "POST", + .path_template = "/api/instances/{component}/{name}/update", + .category = "instances", + .summary = "Apply an available update to a managed instance.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .response = "Update result payload.", + }, + .{ + .id = "orchestration.proxy", + .method = "ANY", + .path_template = "/api/orchestration/{...}", + .category = "orchestration", + .summary = "Proxy orchestration requests to NullBoiler, or store requests to NullTickets.", + .auth_mode = "optional_bearer", + .body = "Forwarded as-is to the orchestration backend.", + .response = "Forwarded upstream JSON response.", + }, +}; + +pub fn allRoutes() []const RouteSpec { + return routes[0..]; +} + +pub fn isRoutesPath(target: []const u8) bool { + return std.mem.eql(u8, target, "/api/meta/routes") or std.mem.startsWith(u8, target, "/api/meta/routes?"); +} + +pub fn jsonAlloc(allocator: std.mem.Allocator) ![]u8 { + return std.json.Stringify.valueAlloc(allocator, Document{ + .version = 1, + .routes = allRoutes(), + }, .{ + .whitespace = .indent_2, + .emit_null_optional_fields = false, + }); +} + +pub fn textAlloc(allocator: std.mem.Allocator) ![]u8 { + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + + const writer = buf.writer(); + try writer.print("nullhub routes ({d})\n", .{routes.len}); + + var current_category: ?[]const u8 = null; + for (allRoutes()) |route| { + if (current_category == null or !std.mem.eql(u8, current_category.?, route.category)) { + current_category = route.category; + try writer.print("\n[{s}]\n", .{route.category}); + } + + try writer.print("{s: >6} {s}", .{ route.method, route.path_template }); + if (route.destructive) { + try buf.appendSlice(" [destructive]"); + } + try buf.appendSlice("\n"); + try writer.print(" {s}\n", .{route.summary}); + + if (route.query_params.len > 0) { + try buf.appendSlice(" query:"); + for (route.query_params, 0..) |param, index| { + if (index > 0) try buf.appendSlice(","); + try writer.print(" {s}", .{param.name}); + } + try buf.appendSlice("\n"); + } + } + + return buf.toOwnedSlice(); +} + +pub fn handleRoutes(allocator: std.mem.Allocator) helpers.ApiResponse { + const body = jsonAlloc(allocator) catch return helpers.serverError(); + return helpers.jsonOk(body); +} + +test "jsonAlloc includes stable route metadata" { + const json = try jsonAlloc(std.testing.allocator); + defer std.testing.allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"id\": \"meta.routes.get\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "/api/instances/{component}/{name}") != null); +} + +test "textAlloc renders grouped route list" { + const text = try textAlloc(std.testing.allocator); + defer std.testing.allocator.free(text); + + try std.testing.expect(std.mem.indexOf(u8, text, "[meta]") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "GET /api/meta/routes") != null); +} + +test "isRoutesPath matches meta routes endpoint" { + try std.testing.expect(isRoutesPath("/api/meta/routes")); + try std.testing.expect(isRoutesPath("/api/meta/routes?format=json")); + try std.testing.expect(!isRoutesPath("/api/status")); +} diff --git a/src/bundled_skills/nullhub-admin/SKILL.md b/src/bundled_skills/nullhub-admin/SKILL.md new file mode 100644 index 0000000..a52ee88 --- /dev/null +++ b/src/bundled_skills/nullhub-admin/SKILL.md @@ -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 ` 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 +``` diff --git a/src/cli.zig b/src/cli.zig index 3bd9f67..91cdc2b 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -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, @@ -94,6 +98,7 @@ pub const Command = union(enum) { update_all, config: ConfigOptions, wizard: WizardOptions, + routes: RoutesOptions, api: ApiOptions, service: ServiceCommand, uninstall: UninstallOptions, @@ -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); } @@ -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; @@ -363,6 +383,7 @@ pub fn printUsage() void { \\ logs View instance logs \\ config View/edit instance config \\ wizard Run setup wizard + \\ routes [--json] List known nullhub API routes \\ check-updates Check for updates \\ update Update an instance \\ update-all Update all instances @@ -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}}' diff --git a/src/installer/orchestrator.zig b/src/installer/orchestrator.zig index d295d1f..b27ffbf 100644 --- a/src/installer/orchestrator.zig +++ b/src/installer/orchestrator.zig @@ -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 ─────────────────────────────────────────────────────────────────── @@ -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( diff --git a/src/main.zig b/src/main.zig index de236ad..5777c9d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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; @@ -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) { diff --git a/src/managed_skills.zig b/src/managed_skills.zig new file mode 100644 index 0000000..ea2bb53 --- /dev/null +++ b/src/managed_skills.zig @@ -0,0 +1,357 @@ +const std = @import("std"); + +pub const CatalogEntry = struct { + name: []const u8, + version: []const u8, + description: []const u8, + author: []const u8 = "", + recommended: bool = false, + install_kind: []const u8, + source: ?[]const u8 = null, + homepage_url: ?[]const u8 = null, + clawhub_slug: ?[]const u8 = null, + always: bool = false, + required_allowed_commands: []const []const u8 = &.{}, +}; + +pub const InstallDisposition = enum { + installed, + updated, +}; + +const BundledSkill = struct { + entry: CatalogEntry, + instructions: []const u8, +}; + +const clawhub_url = "https://clawhub.ai"; + +const bundled_skills = [_]BundledSkill{ + .{ + .entry = .{ + .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.", + .recommended = true, + .install_kind = "bundled", + .homepage_url = clawhub_url, + .always = true, + .required_allowed_commands = &.{"nullhub *"}, + }, + .instructions = @embedFile("bundled_skills/nullhub-admin/SKILL.md"), + }, +}; + +pub fn catalogForComponent(component: []const u8) []const BundledSkill { + if (std.mem.eql(u8, component, "nullclaw")) return bundled_skills[0..]; + return &.{}; +} + +pub fn installBundledSkill( + allocator: std.mem.Allocator, + workspace_dir: []const u8, + skill_name: []const u8, +) !InstallDisposition { + const bundled = findBundledSkill(skill_name) orelse return error.SkillNotFound; + + const skills_dir = try std.fs.path.join(allocator, &.{ workspace_dir, "skills" }); + defer allocator.free(skills_dir); + try ensurePathAbsolute(skills_dir); + + const skill_dir = try std.fs.path.join(allocator, &.{ skills_dir, bundled.entry.name }); + defer allocator.free(skill_dir); + try ensurePathAbsolute(skill_dir); + + const skill_md_path = try std.fs.path.join(allocator, &.{ skill_dir, "SKILL.md" }); + defer allocator.free(skill_md_path); + + const existing = readOptionalFileAlloc(allocator, skill_md_path, bundled.instructions.len + 4096) catch null; + defer if (existing) |bytes| allocator.free(bytes); + + const file = try std.fs.createFileAbsolute(skill_md_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(bundled.instructions); + + if (existing) |bytes| { + if (std.mem.eql(u8, bytes, bundled.instructions)) return .installed; + return .updated; + } + return .installed; +} + +pub fn installAlwaysBundledSkills( + allocator: std.mem.Allocator, + component: []const u8, + workspace_dir: []const u8, + config_path: []const u8, +) !bool { + var config_changed = false; + for (catalogForComponent(component)) |bundled| { + if (!bundled.entry.always) continue; + _ = try installBundledSkill(allocator, workspace_dir, bundled.entry.name); + config_changed = (try syncBundledSkillRuntime(allocator, config_path, bundled.entry.name)) or config_changed; + } + return config_changed; +} + +pub fn syncBundledSkillRuntime( + allocator: std.mem.Allocator, + config_path: []const u8, + skill_name: []const u8, +) !bool { + const bundled = findBundledSkill(skill_name) orelse return error.SkillNotFound; + return syncAllowedCommands(allocator, config_path, bundled.entry.required_allowed_commands); +} + +fn ensurePathAbsolute(path: []const u8) !void { + std.fs.cwd().makePath(path) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; +} + +fn findBundledSkill(name: []const u8) ?BundledSkill { + for (bundled_skills) |bundled| { + if (std.mem.eql(u8, bundled.entry.name, name)) return bundled; + } + return null; +} + +fn syncAllowedCommands( + allocator: std.mem.Allocator, + config_path: []const u8, + required_allowed_commands: []const []const u8, +) !bool { + if (required_allowed_commands.len == 0) return false; + + const file = std.fs.openFileAbsolute(config_path, .{}) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(); + + const contents = try file.readToEndAlloc(allocator, 8 * 1024 * 1024); + defer allocator.free(contents); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, contents, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + if (parsed.value != .object) return error.InvalidConfig; + const root = &parsed.value.object; + const autonomy = try ensureObjectField(allocator, root, "autonomy"); + + if (autonomy.get("level")) |level_value| { + if (level_value == .string and + (std.mem.eql(u8, level_value.string, "full") or std.mem.eql(u8, level_value.string, "yolo"))) + { + return false; + } + } + + if (autonomy.get("allowed_commands")) |existing_value| { + if (existing_value == .array) { + for (existing_value.array.items) |item| { + if (item == .string and std.mem.eql(u8, item.string, "*")) return false; + } + } + } + + const allowed_value = try ensureArrayField(allocator, autonomy, "allowed_commands"); + var changed = false; + for (required_allowed_commands) |command| { + if (arrayContainsString(allowed_value.*, command)) continue; + try allowed_value.append(.{ .string = command }); + changed = true; + } + if (!changed) return false; + + const rendered = try std.json.Stringify.valueAlloc(allocator, parsed.value, .{ + .whitespace = .indent_2, + .emit_null_optional_fields = false, + }); + defer allocator.free(rendered); + + const out = try std.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer out.close(); + try out.writeAll(rendered); + try out.writeAll("\n"); + return true; +} + +fn ensureObjectField( + allocator: std.mem.Allocator, + obj: *std.json.ObjectMap, + key: []const u8, +) !*std.json.ObjectMap { + const gop = try obj.getOrPut(key); + if (!gop.found_existing) { + gop.value_ptr.* = .{ .object = std.json.ObjectMap.init(allocator) }; + return &gop.value_ptr.object; + } + if (gop.value_ptr.* != .object) { + gop.value_ptr.* = .{ .object = std.json.ObjectMap.init(allocator) }; + } + return &gop.value_ptr.object; +} + +fn ensureArrayField( + allocator: std.mem.Allocator, + obj: *std.json.ObjectMap, + key: []const u8, +) !*std.json.Array { + const gop = try obj.getOrPut(key); + if (!gop.found_existing) { + gop.value_ptr.* = .{ .array = std.json.Array.init(allocator) }; + return &gop.value_ptr.array; + } + if (gop.value_ptr.* != .array) { + gop.value_ptr.* = .{ .array = std.json.Array.init(allocator) }; + } + return &gop.value_ptr.array; +} + +fn arrayContainsString(values: std.json.Array, expected: []const u8) bool { + for (values.items) |value| { + if (value == .string and std.mem.eql(u8, value.string, expected)) return true; + } + return false; +} + +fn readOptionalFileAlloc( + allocator: std.mem.Allocator, + path: []const u8, + max_bytes: usize, +) !?[]u8 { + const file = std.fs.openFileAbsolute(path, .{}) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer file.close(); + return try file.readToEndAlloc(allocator, max_bytes); +} + +const ParsedAutonomyConfig = struct { + autonomy: struct { + level: ?[]const u8 = null, + allowed_commands: ?[]const []const u8 = null, + } = .{}, +}; + +fn parseAutonomyConfig(allocator: std.mem.Allocator, config_path: []const u8) !std.json.Parsed(ParsedAutonomyConfig) { + const bytes = try std.fs.readFileAbsolute(allocator, config_path, 64 * 1024); + defer allocator.free(bytes); + return try std.json.parseFromSlice(ParsedAutonomyConfig, allocator, bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); +} + +test "catalogForComponent returns nullclaw recommendations" { + const catalog = catalogForComponent("nullclaw"); + try std.testing.expect(catalog.len > 0); + try std.testing.expectEqualStrings("nullhub-admin", catalog[0].entry.name); + try std.testing.expect(catalog[0].entry.recommended); +} + +test "installBundledSkill writes embedded skill to workspace" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const cwd_path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(cwd_path); + + const disposition = try installBundledSkill(allocator, cwd_path, "nullhub-admin"); + try std.testing.expectEqual(.installed, disposition); + + const skill_path = try std.fs.path.join(allocator, &.{ cwd_path, "skills", "nullhub-admin", "SKILL.md" }); + defer allocator.free(skill_path); + + const content = try std.fs.readFileAbsolute(allocator, skill_path, 64 * 1024); + defer allocator.free(content); + try std.testing.expect(std.mem.indexOf(u8, content, "nullhub routes --json") != null); +} + +test "syncBundledSkillRuntime preserves supervised level and adds nullhub command" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const cwd_path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(cwd_path); + + const config_path = try std.fs.path.join(allocator, &.{ cwd_path, "config.json" }); + defer allocator.free(config_path); + + const file = try std.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"autonomy\":{\"level\":\"supervised\",\"allowed_commands\":[\"git\"]}}\n"); + + try std.testing.expect(try syncBundledSkillRuntime(allocator, config_path, "nullhub-admin")); + + var parsed = try parseAutonomyConfig(allocator, config_path); + defer parsed.deinit(); + + try std.testing.expectEqualStrings("supervised", parsed.value.autonomy.level.?); + try std.testing.expectEqual(@as(usize, 2), parsed.value.autonomy.allowed_commands.?.len); + try std.testing.expectEqualStrings("git", parsed.value.autonomy.allowed_commands.?[0]); + try std.testing.expectEqualStrings("nullhub *", parsed.value.autonomy.allowed_commands.?[1]); +} + +test "syncBundledSkillRuntime preserves full level without narrowing access" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const cwd_path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(cwd_path); + + const config_path = try std.fs.path.join(allocator, &.{ cwd_path, "config.json" }); + defer allocator.free(config_path); + + const file = try std.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"autonomy\":{\"level\":\"full\",\"allowed_commands\":[]}}\n"); + + try std.testing.expect(!(try syncBundledSkillRuntime(allocator, config_path, "nullhub-admin"))); + + var parsed = try parseAutonomyConfig(allocator, config_path); + defer parsed.deinit(); + + try std.testing.expectEqualStrings("full", parsed.value.autonomy.level.?); + try std.testing.expectEqual(@as(usize, 0), parsed.value.autonomy.allowed_commands.?.len); +} + +test "installAlwaysBundledSkills installs skill and syncs runtime access" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const cwd_path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(cwd_path); + + const workspace_dir = try std.fs.path.join(allocator, &.{ cwd_path, "workspace" }); + defer allocator.free(workspace_dir); + try ensurePathAbsolute(workspace_dir); + + const config_path = try std.fs.path.join(allocator, &.{ cwd_path, "config.json" }); + defer allocator.free(config_path); + const file = try std.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"autonomy\":{\"level\":\"supervised\"}}\n"); + + try std.testing.expect(try installAlwaysBundledSkills(allocator, "nullclaw", workspace_dir, config_path)); + + const skill_path = try std.fs.path.join(allocator, &.{ workspace_dir, "skills", "nullhub-admin", "SKILL.md" }); + defer allocator.free(skill_path); + const skill_content = try std.fs.readFileAbsolute(allocator, skill_path, 64 * 1024); + defer allocator.free(skill_content); + try std.testing.expect(std.mem.indexOf(u8, skill_content, "nullhub api ") != null); + + const rendered = try std.fs.readFileAbsolute(allocator, config_path, 64 * 1024); + defer allocator.free(rendered); + try std.testing.expect(std.mem.indexOf(u8, rendered, "\"nullhub *\"") != null); +} diff --git a/src/root.zig b/src/root.zig index e5f6cd0..671ac26 100644 --- a/src/root.zig +++ b/src/root.zig @@ -14,6 +14,8 @@ pub const instances_api = @import("api/instances.zig"); pub const logs_api = @import("api/logs.zig"); pub const main = @import("main.zig"); pub const manager = @import("supervisor/manager.zig"); +pub const managed_skills = @import("managed_skills.zig"); +pub const meta_api = @import("api/meta.zig"); pub const mdns = @import("mdns.zig"); pub const orchestrator = @import("installer/orchestrator.zig"); pub const manifest = @import("core/manifest.zig"); @@ -28,6 +30,7 @@ pub const settings_api = @import("api/settings.zig"); pub const state = @import("core/state.zig"); pub const status_cli = @import("status_cli.zig"); pub const status_api = @import("api/status.zig"); +pub const routes_cli = @import("routes_cli.zig"); pub const ui_modules = @import("installer/ui_modules.zig"); pub const updates_api = @import("api/updates.zig"); pub const versions = @import("installer/versions.zig"); @@ -54,6 +57,8 @@ test { _ = logs_api; _ = main; _ = manager; + _ = managed_skills; + _ = meta_api; _ = mdns; _ = orchestrator; _ = manifest; @@ -68,6 +73,7 @@ test { _ = state; _ = status_cli; _ = status_api; + _ = routes_cli; _ = ui_modules; _ = updates_api; _ = versions; diff --git a/src/routes_cli.zig b/src/routes_cli.zig new file mode 100644 index 0000000..5bd55bb --- /dev/null +++ b/src/routes_cli.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const meta_api = @import("api/meta.zig"); +const cli = @import("cli.zig"); + +pub fn run(allocator: std.mem.Allocator, opts: cli.RoutesOptions) !void { + const output = if (opts.json) + try meta_api.jsonAlloc(allocator) + else + try meta_api.textAlloc(allocator); + defer allocator.free(output); + + var out_buf: [4096]u8 = undefined; + var bw = std.fs.File.stdout().writer(&out_buf); + const w = &bw.interface; + try w.writeAll(output); + if (output.len == 0 or output[output.len - 1] != '\n') { + try w.writeAll("\n"); + } + try w.flush(); +} diff --git a/src/server.zig b/src/server.zig index 5f30e85..6d00e76 100644 --- a/src/server.zig +++ b/src/server.zig @@ -5,6 +5,7 @@ const platform = @import("core/platform.zig"); const components_api = @import("api/components.zig"); const config_api = @import("api/config.zig"); const logs_api = @import("api/logs.zig"); +const meta_api = @import("api/meta.zig"); const status_api = @import("api/status.zig"); const settings_api = @import("api/settings.zig"); const updates_api = @import("api/updates.zig"); @@ -460,6 +461,10 @@ pub const Server = struct { const resp = status_api.handleStatus(allocator, self.state, self.manager, uptime, self.host, self.port, self.currentAccessOptions()); return .{ .status = resp.status, .content_type = resp.content_type, .body = resp.body }; } + if (meta_api.isRoutesPath(target)) { + const resp = meta_api.handleRoutes(allocator); + return .{ .status = resp.status, .content_type = resp.content_type, .body = resp.body }; + } if (std.mem.eql(u8, target, "/api/components")) { if (components_api.handleList(allocator, self.state)) |json| { return .{ @@ -1276,6 +1281,18 @@ test "route GET /api/status returns version and platform" { try std.testing.expect(std.mem.indexOf(u8, resp.body, "uptime_seconds") != null); } +test "route GET /api/meta/routes returns route catalog" { + var ctx = TestContext.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const resp = ctx.route(std.testing.allocator, "GET", "/api/meta/routes", ""); + defer std.testing.allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expectEqualStrings("application/json", resp.content_type); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"id\": \"meta.routes.get\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "/api/instances/{component}/{name}") != null); +} + test "route unknown non-API path attempts static file serving" { var ctx = TestContext.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator); diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index a663ba9..f3ee429 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -27,7 +27,12 @@ async function request(path: string, options?: RequestInit): Promise { }); if (!res.ok) { const body = await res.json().catch(() => null); - const errMsg = typeof body?.error === 'string' ? body.error : body?.error?.message || `HTTP ${res.status}`; + const errMsg = + typeof body?.message === 'string' + ? body.message + : typeof body?.error === 'string' + ? body.error + : body?.error?.message || `HTTP ${res.status}`; throw new Error(errMsg); } if (res.status === 204) return undefined as T; @@ -102,6 +107,27 @@ export const api = { ), getSkills: (c: string, n: string, name?: string) => request(withQuery(`/instances/${c}/${n}/skills`, { name })), + getSkillCatalog: (c: string, n: string) => + request(withQuery(`/instances/${c}/${n}/skills`, { catalog: 1 })), + installBundledSkill: (c: string, n: string, bundled: string) => + request(`/instances/${c}/${n}/skills`, { + method: 'POST', + body: JSON.stringify({ bundled }), + }), + installSkillFromClawhub: (c: string, n: string, clawhub_slug: string) => + request(`/instances/${c}/${n}/skills`, { + method: 'POST', + body: JSON.stringify({ clawhub_slug }), + }), + installSkillFromSource: (c: string, n: string, source: string) => + request(`/instances/${c}/${n}/skills`, { + method: 'POST', + body: JSON.stringify({ source }), + }), + removeSkill: (c: string, n: string, skillName: string) => + request(withQuery(`/instances/${c}/${n}/skills`, { name: skillName }), { + method: 'DELETE', + }), getIntegration: (c: string, n: string) => request(`/instances/${c}/${n}/integration`), linkIntegration: (c: string, n: string, payload: any) => diff --git a/ui/src/lib/components/InstanceSkillsPanel.svelte b/ui/src/lib/components/InstanceSkillsPanel.svelte index 0cae43d..c318e76 100644 --- a/ui/src/lib/components/InstanceSkillsPanel.svelte +++ b/ui/src/lib/components/InstanceSkillsPanel.svelte @@ -19,6 +19,24 @@ instructions_bytes: number; }; + type CatalogEntry = { + name: string; + version: string; + description: string; + author?: string; + recommended: boolean; + install_kind: string; + source?: string; + homepage_url?: string; + clawhub_slug?: string; + always?: boolean; + }; + + type InstallResult = { + status?: string; + restart_required?: boolean; + }; + let { component, name, active = false } = $props<{ component: string; name: string; @@ -26,12 +44,24 @@ }>(); let skills = $state([]); + let catalog = $state([]); let loading = $state(false); + let catalogLoading = $state(false); let error = $state(null); + let catalogError = $state(null); + let actionError = $state(null); + let actionMessage = $state(null); let loadedKey = $state(""); + let catalogLoadedKey = $state(""); let requestSeq = 0; + let catalogRequestSeq = 0; + let busyAction = $state(null); + let clawhubSlug = $state(""); + let sourceInput = $state(""); const instanceKey = $derived(`${component}/${name}`); + const supportsInstall = $derived(component === "nullclaw"); + const installedSkillNames = $derived(new Set(skills.map((skill) => skill.name))); const sortedSkills = $derived( [...skills].sort((a, b) => { if (a.available !== b.available) return a.available ? -1 : 1; @@ -39,6 +69,12 @@ return a.name.localeCompare(b.name); }), ); + const sortedCatalog = $derived( + [...catalog].sort((a, b) => { + if (a.recommended !== b.recommended) return a.recommended ? -1 : 1; + return a.name.localeCompare(b.name); + }), + ); async function loadSkills(force = false) { if (!active || !component || !name) return; @@ -71,17 +107,134 @@ } } - function refreshSkills() { + async function loadCatalog(force = false) { + if (!active || !supportsInstall || !component || !name) return; + const contextKey = instanceKey; + const nextKey = `${contextKey}:skills:catalog`; + if (!force && catalogLoadedKey === nextKey) return; + + const req = ++catalogRequestSeq; + catalogLoading = true; + catalogError = null; + try { + const result = await api.getSkillCatalog(component, name); + if (req !== catalogRequestSeq || contextKey !== instanceKey || !active) return; + catalog = Array.isArray(result) ? result : []; + catalogLoadedKey = nextKey; + } catch (err) { + if (req !== catalogRequestSeq || contextKey !== instanceKey || !active) return; + catalog = []; + catalogError = (err as Error).message || "Failed to load recommended skills."; + } finally { + if (req === catalogRequestSeq && contextKey === instanceKey) { + catalogLoading = false; + } + } + } + + async function refreshAll() { loadedKey = ""; - void loadSkills(true); + catalogLoadedKey = ""; + await Promise.all([loadSkills(true), loadCatalog(true)]); + } + + async function installBundled(entry: CatalogEntry) { + actionError = null; + actionMessage = null; + busyAction = `bundled:${entry.name}`; + try { + const result = await api.installBundledSkill(component, name, entry.name) as InstallResult; + if (isInstanceCliError(result)) throw new Error(describeInstanceCliError(result, `Failed to install ${entry.name}.`)); + const baseMessage = result?.status === "updated" + ? `Updated ${entry.name}.` + : `Installed ${entry.name}.`; + actionMessage = result?.restart_required + ? `${baseMessage} Restart this instance if it is already running to apply nullhub command access.` + : baseMessage; + await refreshAll(); + } catch (err) { + actionError = (err as Error).message || `Failed to install ${entry.name}.`; + } finally { + busyAction = null; + } + } + + async function installFromClawhub() { + const slug = clawhubSlug.trim(); + if (!slug) { + actionError = "Enter a ClawHub slug first."; + actionMessage = null; + return; + } + actionError = null; + actionMessage = null; + busyAction = `clawhub:${slug}`; + try { + const result = await api.installSkillFromClawhub(component, name, slug); + if (isInstanceCliError(result)) throw new Error(describeInstanceCliError(result, `Failed to install ${slug} from ClawHub.`)); + clawhubSlug = ""; + actionMessage = `Installed ${slug} from ClawHub.`; + await refreshAll(); + } catch (err) { + actionError = (err as Error).message || `Failed to install ${slug} from ClawHub.`; + } finally { + busyAction = null; + } + } + + async function installFromSource() { + const source = sourceInput.trim(); + if (!source) { + actionError = "Enter a git URL or local path first."; + actionMessage = null; + return; + } + actionError = null; + actionMessage = null; + busyAction = `source:${source}`; + try { + const result = await api.installSkillFromSource(component, name, source); + if (isInstanceCliError(result)) throw new Error(describeInstanceCliError(result, "Failed to install skill from source.")); + sourceInput = ""; + actionMessage = "Installed skill from source."; + await refreshAll(); + } catch (err) { + actionError = (err as Error).message || "Failed to install skill from source."; + } finally { + busyAction = null; + } + } + + async function removeSkill(skillName: string) { + actionError = null; + actionMessage = null; + busyAction = `remove:${skillName}`; + try { + const result = await api.removeSkill(component, name, skillName); + if (isInstanceCliError(result)) throw new Error(describeInstanceCliError(result, `Failed to remove ${skillName}.`)); + actionMessage = `Removed ${skillName}.`; + await refreshAll(); + } catch (err) { + actionError = (err as Error).message || `Failed to remove ${skillName}.`; + } finally { + busyAction = null; + } + } + + function installLabel(entry: CatalogEntry) { + if (entry.install_kind === "bundled") return "Bundled"; + if (entry.install_kind === "clawhub") return "ClawHub"; + return "Source"; } $effect(() => { if (!active || !component || !name) return; - if (loadedKey === `${instanceKey}:skills`) return; + if (loadedKey === `${instanceKey}:skills` && (!supportsInstall || catalogLoadedKey === `${instanceKey}:skills:catalog`)) return; skills = []; + catalog = []; error = null; - void loadSkills(true); + catalogError = null; + void refreshAll(); }); @@ -91,9 +244,130 @@

Skills

Installed prompt skills visible to this instance workspace.

- + + {#if supportsInstall} +
+
+
+

Recommended

+

Install managed skills that teach this nullclaw how to operate sibling tools from the Null ecosystem.

+
+
+ + {#if actionMessage} +
{actionMessage}
+ {/if} + {#if actionError} +
{actionError}
+ {/if} + {#if catalogError} +
{catalogError}
+ {:else if catalogLoading && sortedCatalog.length === 0} +
Loading recommended skills...
+ {:else if sortedCatalog.length > 0} +
+ {#each sortedCatalog as entry} + + {/each} +
+ {/if} + +
+
{ + event.preventDefault(); + void installFromClawhub(); + }}> +
+

Install From ClawHub

+

Paste a published ClawHub slug. NullHub will run clawhub install inside this instance workspace.

+
+ +
+ + Browse ClawHub +
+
+ +
{ + event.preventDefault(); + void installFromSource(); + }}> +
+

Install From Source

+

Use a git URL or local skill path. This goes through nullclaw skills install.

+
+ +
+ +
+
+
+
+ {/if} + {#if error}
{error}
{:else if loading && skills.length === 0} @@ -145,6 +419,12 @@ {#if skill.missing_deps}
Missing deps: {skill.missing_deps}
{/if} + + {#if skill.source === "workspace" && supportsInstall} +
+ +
+ {/if} {/each} @@ -157,6 +437,66 @@ flex-direction: column; gap: 1rem; } + .skill-section { + display: flex; + flex-direction: column; + gap: 0.9rem; + padding: 1rem; + border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--border)); + background: color-mix(in srgb, var(--bg-surface) 94%, transparent); + border-radius: 4px; + } + .section-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + } + .section-header h3 { + margin: 0; + color: var(--accent); + font-size: 1rem; + } + .section-header p { + margin: 0.25rem 0 0; + color: var(--fg-dim); + font-size: 0.84rem; + line-height: 1.45; + } + .install-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 0.9rem; + } + .install-card { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-panel, var(--bg-surface)) 90%, transparent); + border-radius: 4px; + } + .install-card h4 { + margin: 0; + font-size: 0.95rem; + } + .install-card p { + margin: 0.25rem 0 0; + color: var(--fg-dim); + font-size: 0.82rem; + line-height: 1.45; + } + .install-card input { + width: 100%; + padding: 0.7rem 0.8rem; + border: 1px solid var(--border); + background: var(--bg); + color: var(--fg); + border-radius: 3px; + font-family: var(--font-mono); + font-size: 0.82rem; + } .panel-toolbar { display: flex; justify-content: space-between; @@ -173,7 +513,11 @@ color: var(--fg-dim); font-size: 0.875rem; } - .toolbar-btn { + .toolbar-btn, + .toolbar-link { + display: inline-flex; + align-items: center; + justify-content: center; padding: 0.55rem 0.9rem; border: 1px solid var(--accent-dim); background: var(--bg-surface); @@ -184,13 +528,18 @@ text-transform: uppercase; letter-spacing: 1px; cursor: pointer; + text-decoration: none; } .toolbar-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .toolbar-btn.danger { + border-color: color-mix(in srgb, var(--danger, #ef4444) 50%, transparent); + color: var(--danger, #ef4444); + } .panel-state { - padding: 1.5rem; + padding: 1rem 1.15rem; border: 1px dashed color-mix(in srgb, var(--border) 75%, transparent); background: color-mix(in srgb, var(--bg-surface) 82%, transparent); color: var(--fg-dim); @@ -202,6 +551,11 @@ color: var(--warning, #f59e0b); background: color-mix(in srgb, var(--warning, #f59e0b) 8%, transparent); } + .panel-state.success { + border-color: color-mix(in srgb, var(--success, #22c55e) 50%, transparent); + color: var(--success, #22c55e); + background: color-mix(in srgb, var(--success, #22c55e) 8%, transparent); + } .skill-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); @@ -216,6 +570,12 @@ background: var(--bg-surface); border-radius: 4px; } + .skill-card.recommended { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + } + .skill-card.installed { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--success, #22c55e) 28%, transparent); + } .skill-card.missing { border-color: color-mix(in srgb, var(--warning, #f59e0b) 45%, transparent); } @@ -265,46 +625,50 @@ } .skill-meta { display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.75rem; } .skill-meta div { display: flex; flex-direction: column; - gap: 0.25rem; - padding: 0.75rem; - border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); - border-radius: 4px; + gap: 0.15rem; + min-width: 0; } .skill-meta span { - color: var(--accent-dim); - font-size: 0.7rem; + font-size: 0.68rem; text-transform: uppercase; letter-spacing: 1px; + color: var(--fg-muted); + } + .skill-meta strong { + font-size: 0.82rem; + line-height: 1.35; + word-break: break-word; } .skill-path, .missing-deps { - word-break: break-all; - font-size: 0.78rem; - } - .skill-path { + font-size: 0.8rem; color: var(--fg-dim); } - .missing-deps { - color: var(--warning, #f59e0b); + .skill-path { + padding: 0.6rem 0.75rem; + border-radius: 3px; + background: color-mix(in srgb, var(--bg) 82%, transparent); + overflow-wrap: anywhere; } - .mono { - font-family: var(--font-mono); + .skill-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; } - - @media (max-width: 900px) { + @media (max-width: 720px) { .panel-toolbar, + .section-header, .skill-card header { flex-direction: column; - align-items: stretch; } - .skill-badges { - justify-content: flex-start; + .skill-meta { + grid-template-columns: 1fr; } }