From aeeb47aff5052d726e099a25b95140b115195e55 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sat, 17 Oct 2026 18:09:31 -0500 Subject: [PATCH] sdk: a plugin can install a program beside itself fizzy.plugin.installBeside copies another artifact into the plugin's own directory on zig build install, through the same helper program as the plugin's library, which now keeps the file's permissions. The agent plugin installs fizzy-mcp this way; on 0.16 it used a custom build step, and Zig 0.17 has none. Co-Authored-By: Claude Opus 5.5 --- docs/PLUGINS.md | 5 +++++ sdk/plugin_sdk.zig | 32 +++++++++++++++++++++++++++----- sdk/plugin_sdk_install.zig | 14 ++++++++------ 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 3ad29197c..abe6e55cd 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -223,6 +223,11 @@ libraries (vendored C, packed assets). See `fizzy.plugin.addCModule` and [pixi's `build.zig`](https://github.com/fizzyedit/pixi/blob/main/build.zig) for a worked example with vendored C deps. +A plugin that ships a program of its own (a helper it starts, as the agent plugin starts +`fizzy-mcp`) builds it as usual and passes it to `fizzy.plugin.installBeside(b, exe, .{ .plugin = +"" })`: `zig build install` then copies it into the plugin's own directory, beside the +library and still executable, where the plugin finds it at run time. + Under `create`, the dylib's C-ABI entry point (the five symbols the host looks up at `dlopen` time — ABI fingerprint check, the `register` entry, and injection hooks for the allocator/`*Host`/dvui-context/render-bridge — see §7 for the full list) is a tiny **generated** diff --git a/sdk/plugin_sdk.zig b/sdk/plugin_sdk.zig index 6025a3d2e..b5edb1e6b 100644 --- a/sdk/plugin_sdk.zig +++ b/sdk/plugin_sdk.zig @@ -254,6 +254,28 @@ pub fn install(b: *std.Build, lib: *std.Build.Step.Compile, opts: InstallOptions // convenience). Not for a wasm build: the browser has no plugins directory, and the desktop // editor would only find a file it cannot open. if (lib.rootModuleTarget().cpu.arch == .wasm32) return; + addDevInstall(b, lib, name, dest, b.fmt("install plugin '{s}' into fizzy plugins dir", .{name})); +} + +pub const InstallBesideOptions = struct { + /// The plugin's manifest `id`: the directory the artifact joins. + plugin: []const u8, +}; + +/// Wire `zig build install` to drop a second artifact (a helper program the plugin starts, say) +/// into the plugin's own `{plugins}/{plugin}/` directory, beside the library `install` puts there, +/// under the artifact's own file name and with its permissions. Like `install`'s copy, a dev +/// convenience: a release ships what the plugin's `release.yml` uploads. +/// +/// fizzy.plugin.installBeside(b, helper_exe, .{ .plugin = "agent" }); +pub fn installBeside(b: *std.Build, artifact: *std.Build.Step.Compile, opts: InstallBesideOptions) void { + if (artifact.rootModuleTarget().cpu.arch == .wasm32) return; + addDevInstall(b, artifact, opts.plugin, artifact.out_filename, b.fmt("install '{s}' beside plugin '{s}'", .{ artifact.out_filename, opts.plugin })); +} + +/// Copy `artifact` to `{plugins}/{dir_name}/{file_name}` on every `zig build install`, through +/// `plugin_sdk_install.zig`: Zig 0.17's build has no custom steps. +fn addDevInstall(b: *std.Build, artifact: *std.Build.Step.Compile, dir_name: []const u8, file_name: []const u8, step_name: []const u8) void { const fizzy_dep = fizzyDep(b, .{ .target = b.graph.host, .optimize = .fast }); const dev_exe = b.addExecutable(.{ .name = "fizzy-plugin-dev-install", @@ -264,13 +286,13 @@ pub fn install(b: *std.Build, lib: *std.Build.Step.Compile, opts: InstallOptions }), }); const dev = b.addRunArtifact(dev_exe); - dev.setName(b.fmt("install plugin '{s}' into fizzy plugins dir", .{name})); - dev.addArtifactArg(lib); - dev.addArgs(&.{ name, dest }); + dev.setName(step_name); + dev.addArtifactArg(artifact); + dev.addArgs(&.{ dir_name, file_name }); // A relative `FIZZY_PROFILE` is taken from the plugin's own directory. dev.setCwd(b.path(".")); - // Every install, not only when the dylib changed: the copy in the plugins dir may have been - // replaced or removed since. + // Every install, not only when the artifact changed: the copy in the plugins dir may have + // been replaced or removed since. dev.has_side_effects = true; b.getInstallStep().dependOn(&dev.step); } diff --git a/sdk/plugin_sdk_install.zig b/sdk/plugin_sdk_install.zig index df103837a..d2c11a058 100644 --- a/sdk/plugin_sdk_install.zig +++ b/sdk/plugin_sdk_install.zig @@ -1,8 +1,8 @@ -//! Copy a built plugin into fizzy's plugins directory: `zig build install`'s dev convenience, -//! wired by `fizzy.plugin.install`. A program run by a Run step, because Zig 0.17's build has no -//! custom steps. +//! Copy a built plugin, or a program it installs beside itself, into fizzy's plugins directory: +//! `zig build install`'s dev convenience, wired by `fizzy.plugin.install` and `.installBeside`. +//! A program run by a Run step, because Zig 0.17's build has no custom steps. //! -//! Usage: `plugin_sdk_install `. It lands at +//! Usage: `plugin_sdk_install `. It lands at //! `//`, where `` is `$FIZZY_PROFILE/plugins` when that is set //! (a relative profile is taken from the working directory, the plugin's own) and otherwise the //! host's fizzy plugins directory, the one the editor scans. Read here, at make time, so the @@ -34,14 +34,16 @@ pub fn main(init: std.process.Init) !void { const dest = try std.fs.path.join(arena, &.{ dir, file_name }); const data = try cwd.readFileAlloc(io, src, arena, .limited(512 * 1024 * 1024)); + // The source's permissions, so a program installed beside the plugin stays executable. + const permissions = (try cwd.statFile(io, src, .{})).permissions; // Sibling temp file + rename, not a truncating write onto `dest`: a running fizzy may be // reading `dest` right now (`PluginLoader.stableLoadCopyPath` copies from it), and on Windows // a plain overwrite of a file the editor still has open fails outright. The rename also means // a crash mid-write never leaves a half-written dylib at the load path. const tmp = try std.fmt.allocPrint(arena, "{s}.part", .{dest}); - try cwd.writeFile(io, .{ .sub_path = tmp, .data = data }); + try cwd.writeFile(io, .{ .sub_path = tmp, .data = data, .flags = .{ .permissions = permissions } }); try std.Io.Dir.rename(cwd, tmp, cwd, dest, io); - std.log.info("fizzy: installed plugin → {s}", .{dest}); + std.log.info("fizzy: installed {s} → {s}", .{ file_name, dest }); } /// `{local_config}/fizzy/plugins`, mirroring the runtime loader (`core/paths.zig`), or