agents-can-communicate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/bin/acc-hook.mjs +53 -0
- package/bin/acc-mcp.mjs +46 -0
- package/bin/acc.mjs +24 -0
- package/docs/CAPABILITIES.md +153 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
- package/node_modules/@agents-can-communicate/cli/package.json +12 -0
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
- package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
- package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
- package/node_modules/@agents-can-communicate/core/package.json +12 -0
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
- package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
- package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
- package/node_modules/@agents-can-communicate/installer/package.json +10 -0
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
- package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
- package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
- package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
- package/package.json +81 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { normalizedEvent } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
// Not read from documentation and not guessed: this client validates its config
|
|
5
|
+
// with a strict schema, and rejecting an empty hook entry makes it name every
|
|
6
|
+
// accepted event. See COMPATIBILITY.md for the transcript.
|
|
7
|
+
export const KIMI_HOOK_EVENTS = Object.freeze(["PreToolUse", "PostToolUse",
|
|
8
|
+
"PostToolUseFailure", "PermissionRequest", "PermissionResult", "UserPromptSubmit",
|
|
9
|
+
"UserPromptQueued", "TurnStarted", "Stop", "StopFailure", "Interrupt", "SessionStart",
|
|
10
|
+
"SessionEnd", "SessionHeartbeat", "SubagentStart", "SubagentStop", "TaskStarted",
|
|
11
|
+
"PreCompact", "PostCompact", "Notification"]);
|
|
12
|
+
|
|
13
|
+
// The tools this client actually names. Codex calls its editor apply_patch, and
|
|
14
|
+
// a matcher borrowed across harnesses guards nothing at all, so these were read
|
|
15
|
+
// out of a real request rather than assumed from the family resemblance.
|
|
16
|
+
export const KIMI_EDIT_TOOLS = Object.freeze(["Write", "Edit"]);
|
|
17
|
+
export const KIMI_SHELL_TOOLS = Object.freeze(["Bash"]);
|
|
18
|
+
|
|
19
|
+
const KIND_BY_EVENT = Object.freeze({
|
|
20
|
+
SessionStart: "sessionStart",
|
|
21
|
+
SessionEnd: "sessionEnd",
|
|
22
|
+
SessionHeartbeat: "heartbeat",
|
|
23
|
+
UserPromptSubmit: "beforeTurn",
|
|
24
|
+
PreToolUse: "beforeTool",
|
|
25
|
+
PostToolUse: "afterTool",
|
|
26
|
+
PostToolUseFailure: "afterTool",
|
|
27
|
+
Stop: "turnEnd",
|
|
28
|
+
StopFailure: "turnEnd",
|
|
29
|
+
SubagentStart: "childStart",
|
|
30
|
+
SubagentStop: "childEnd",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalise a Kimi Code hook payload.
|
|
35
|
+
*
|
|
36
|
+
* A whitelist, not a filter. `TurnStarted` carries the raw prompt, and
|
|
37
|
+
* `UserPromptSubmit` carries it as content blocks; `PostToolUse` carries the
|
|
38
|
+
* tool's output and `PostToolUseFailure` the error text. This client hands
|
|
39
|
+
* conversation content to hooks directly, so keeping it out is a property of
|
|
40
|
+
* this function rather than an absence of opportunity.
|
|
41
|
+
*/
|
|
42
|
+
export function normalizeKimiHook(payload) {
|
|
43
|
+
const event = payload?.hook_event_name;
|
|
44
|
+
if (typeof event !== "string" || !KIMI_HOOK_EVENTS.includes(event)) {
|
|
45
|
+
throw new AccError(EXIT.DATA, "unrecognised Kimi Code hook event",
|
|
46
|
+
{ event: event ?? null });
|
|
47
|
+
}
|
|
48
|
+
if (typeof payload.session_id !== "string" || typeof payload.cwd !== "string") {
|
|
49
|
+
throw new AccError(EXIT.DATA, "hook payload has no session id or working directory",
|
|
50
|
+
{ event, received: Object.keys(payload) });
|
|
51
|
+
}
|
|
52
|
+
const tool = typeof payload.tool_name === "string" ? payload.tool_name : null;
|
|
53
|
+
return normalizedEvent({
|
|
54
|
+
kind: KIND_BY_EVENT[event] ?? "other",
|
|
55
|
+
sessionId: payload.session_id,
|
|
56
|
+
cwd: payload.cwd,
|
|
57
|
+
// Only SessionStart carries it; every other event leaves it null rather
|
|
58
|
+
// than inheriting a stale value.
|
|
59
|
+
model: typeof payload.model === "string" ? payload.model : null,
|
|
60
|
+
// No subagent was observed running, so there is no field to map yet and
|
|
61
|
+
// nothing is invented from timing.
|
|
62
|
+
parentSessionId: null,
|
|
63
|
+
tool,
|
|
64
|
+
targets: writeTargets(tool, payload.tool_input),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The paths a tool call would write.
|
|
70
|
+
*
|
|
71
|
+
* Both editing tools take `path` - confirmed from their declared schemas, where
|
|
72
|
+
* `Write` requires `path` and `content` and `Edit` requires `path`,
|
|
73
|
+
* `old_string` and `new_string`. Nothing else is read: the contents and the
|
|
74
|
+
* replacement strings are conversation content and stay out.
|
|
75
|
+
*
|
|
76
|
+
* `Bash` declares nothing. A command can write anywhere, and a path guessed out
|
|
77
|
+
* of one would give a guard that blocks work it holds no claim over while
|
|
78
|
+
* missing writes it does.
|
|
79
|
+
*/
|
|
80
|
+
function writeTargets(tool, input) {
|
|
81
|
+
if (!KIMI_EDIT_TOOLS.includes(tool)) return [];
|
|
82
|
+
const path = input?.path;
|
|
83
|
+
return typeof path === "string" && path !== "" ? [path] : [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Deny a tool call in the one shape this client acts on.
|
|
88
|
+
*
|
|
89
|
+
* Five candidate shapes were run against a real session. Only two stopped the
|
|
90
|
+
* tool: exit code 2, and this structured reply. `{"decision":"block"}`,
|
|
91
|
+
* `{"permission":"deny"}` and exit code 1 all looked plausible and let the write
|
|
92
|
+
* through. This one is preferred over exit 2 because the reason survives: it
|
|
93
|
+
* reaches the model as the failed call's `error.message`.
|
|
94
|
+
*/
|
|
95
|
+
export function denyResponse(reason) {
|
|
96
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse",
|
|
97
|
+
permissionDecision: "deny", permissionDecisionReason: reason } };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function allowResponse() {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Context for the next turn, as this client delivers it.
|
|
106
|
+
*
|
|
107
|
+
* Unlike Claude Code, this client does not unwrap `additionalContext`: it wraps
|
|
108
|
+
* a hook's entire stdout in `<hook_result hook_event="...">` and shows the model
|
|
109
|
+
* whatever that was. Emitting the JSON envelope here would put the envelope
|
|
110
|
+
* itself into the conversation, so the injection is plain text.
|
|
111
|
+
*/
|
|
112
|
+
export function injectResponse(context) {
|
|
113
|
+
return context === "" ? null : context;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// How a denial and an injection reach this client. Both were measured; they use
|
|
117
|
+
// different mechanisms, and neither matches all three of the other adapters.
|
|
118
|
+
export function denyOutcome(reason) {
|
|
119
|
+
return { stdout: `${JSON.stringify(denyResponse(reason))}\n`, stderr: "", exitCode: 0 };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function injectOutcome(context) {
|
|
123
|
+
const rendered = injectResponse(context);
|
|
124
|
+
return { stdout: rendered === null ? "" : `${rendered}\n`, stderr: "", exitCode: 0 };
|
|
125
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { assertRunner, bakeSkillCommand, blankText, defaultRunner, removeIfEmpty,
|
|
8
|
+
removeInstalledTree, runnerExists, writeForeignJson }
|
|
9
|
+
from "@agents-can-communicate/adapter-sdk";
|
|
10
|
+
|
|
11
|
+
const bundle = fileURLToPath(new URL("../plugin", import.meta.url));
|
|
12
|
+
const PLUGIN_NAME = "agents-can-communicate";
|
|
13
|
+
|
|
14
|
+
// The block markers are the whole ownership story. This client keeps its hooks
|
|
15
|
+
// in the user's TOML config, and ACC ships without dependencies, so parsing and
|
|
16
|
+
// re-emitting that file would mean writing a TOML round-tripper and losing the
|
|
17
|
+
// user's comments and formatting to it. Instead ACC owns a delimited region and
|
|
18
|
+
// never reads the rest: install replaces the region, uninstall deletes it, and
|
|
19
|
+
// everything outside comes back byte for byte.
|
|
20
|
+
export const BEGIN = "# >>> agents-can-communicate (managed; edits here are overwritten)";
|
|
21
|
+
export const END = "# <<< agents-can-communicate";
|
|
22
|
+
|
|
23
|
+
// Seconds, not milliseconds. A hook that sleeps 3s dies under `timeout = 1`,
|
|
24
|
+
// which is how this was settled; copying another harness's `10000` would have
|
|
25
|
+
// let a hung hook stall a turn for the better part of three hours.
|
|
26
|
+
const TIMEOUT_SECONDS = 10;
|
|
27
|
+
|
|
28
|
+
// Only events observed firing get wired. Guard events carry the matcher, which
|
|
29
|
+
// was proven to select: "NoSuchTool" never fired, "Write|Edit|Bash" fired twice.
|
|
30
|
+
const WIRING = Object.freeze([
|
|
31
|
+
{ event: "SessionStart", kind: "sessionStart" },
|
|
32
|
+
{ event: "UserPromptSubmit", kind: "beforeTurn" },
|
|
33
|
+
{ event: "SessionHeartbeat", kind: "heartbeat" },
|
|
34
|
+
{ event: "PreToolUse", kind: "beforeTool", matcher: "Write|Edit|Bash" },
|
|
35
|
+
{ event: "Stop", kind: "turnEnd" },
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const configPath = home => path.join(home, "config.toml");
|
|
39
|
+
const pluginPath = home => path.join(home, "plugins", "managed", PLUGIN_NAME);
|
|
40
|
+
const registryPath = home => path.join(home, "plugins", "installed.json");
|
|
41
|
+
|
|
42
|
+
// This client's config has no variable to stand in for an install directory -
|
|
43
|
+
// unlike the plugin manifests of the other three, which expand a plugin root -
|
|
44
|
+
// so the runner is written in as an absolute path at install time. Codex taught
|
|
45
|
+
// this the hard way: a relative hook command fails silently, hook after hook.
|
|
46
|
+
|
|
47
|
+
// TOML basic strings take backslash escapes. A path is user-controlled input
|
|
48
|
+
// here, so it is escaped rather than trusted to be boring.
|
|
49
|
+
const tomlString = value => `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
50
|
+
|
|
51
|
+
async function readText(file, fallback) {
|
|
52
|
+
try {
|
|
53
|
+
return await readFile(file, "utf8");
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code === "ENOENT") return fallback;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read a client's own JSON, and say which file when it will not parse.
|
|
62
|
+
*
|
|
63
|
+
* A malformed config is the user's to fix, and the message they get has to name
|
|
64
|
+
* it. `Unexpected end of JSON input` arrived with no path attached, from an
|
|
65
|
+
* install that touches four clients' homes, and left them to guess which.
|
|
66
|
+
*/
|
|
67
|
+
async function readJson(file, fallback) {
|
|
68
|
+
const source = await readText(file, null);
|
|
69
|
+
if (source === null) return fallback;
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(source);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new AccError(EXIT.DATA, `${file} is not valid JSON: ${error.message}`,
|
|
74
|
+
{ file, cause: error.message });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Remove ACC's region and nothing else.
|
|
80
|
+
*
|
|
81
|
+
* Written to survive a config that has no block, several blocks, or a block the
|
|
82
|
+
* user has half-deleted: an unterminated marker consumes to end of file rather
|
|
83
|
+
* than leaving stray `[[hooks]]` entries that would fail the client's schema
|
|
84
|
+
* and lock the user out of their own tool.
|
|
85
|
+
*/
|
|
86
|
+
export function stripBlock(source) {
|
|
87
|
+
const lines = source.split("\n");
|
|
88
|
+
const kept = [];
|
|
89
|
+
let inside = false;
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
if (line.trimEnd() === BEGIN) { inside = true; continue; }
|
|
92
|
+
if (inside) {
|
|
93
|
+
if (line.trimEnd() === END) inside = false;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
kept.push(line);
|
|
97
|
+
}
|
|
98
|
+
return kept.join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function renderBlock(runner, node = process.execPath) {
|
|
102
|
+
const entries = WIRING.map(({ event, kind, matcher }) => {
|
|
103
|
+
// Both paths are shell-quoted inside the TOML string: a hook runs through a
|
|
104
|
+
// shell, and a space in either path would otherwise split the command.
|
|
105
|
+
// The interpreter is named outright rather than left to PATH, which a hook's
|
|
106
|
+
// environment does not reliably carry.
|
|
107
|
+
const command = `${tomlString(node)} ${tomlString(runner)} kimi ${kind}`;
|
|
108
|
+
const lines = ["[[hooks]]", `event = ${tomlString(event)}`,
|
|
109
|
+
`command = ${tomlString(command)}`];
|
|
110
|
+
if (matcher !== undefined) lines.push(`matcher = ${tomlString(matcher)}`);
|
|
111
|
+
lines.push(`timeout = ${TIMEOUT_SECONDS}`);
|
|
112
|
+
return lines.join("\n");
|
|
113
|
+
});
|
|
114
|
+
return [BEGIN, ...entries, END].join("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const registerPlugin = (registry, root) => {
|
|
118
|
+
const plugins = (registry.plugins ?? []).filter(entry => entry.id !== PLUGIN_NAME);
|
|
119
|
+
return { ...registry, version: registry.version ?? 1,
|
|
120
|
+
plugins: [...plugins, { id: PLUGIN_NAME, root, source: "local", enabled: true }] };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export async function installKimiPlugin({ home, runner = defaultRunner(), node }) {
|
|
124
|
+
// A hook whose command does not exist fails silently, on every event, for as
|
|
125
|
+
// long as it stays installed: the client reports nothing and ACC simply never
|
|
126
|
+
// sees a session. Writing that entry and hoping is worse than refusing.
|
|
127
|
+
await assertRunner(runner);
|
|
128
|
+
// Read before writing: a registry that will not parse must be found before a
|
|
129
|
+
// plugin tree is laid down that nothing will then be able to remove.
|
|
130
|
+
const registered = await readJson(registryPath(home), { version: 1, plugins: [] });
|
|
131
|
+
|
|
132
|
+
const target = pluginPath(home);
|
|
133
|
+
await rm(target, { recursive: true, force: true });
|
|
134
|
+
await cp(bundle, target, { recursive: true });
|
|
135
|
+
// The skill ships with a placeholder where the command belongs: `acc` is
|
|
136
|
+
// not on PATH everywhere, and an agent that cannot run it improvises.
|
|
137
|
+
await bakeSkillCommand({ root: target, node });
|
|
138
|
+
|
|
139
|
+
const file = configPath(home);
|
|
140
|
+
const existing = await readText(file, "");
|
|
141
|
+
const withoutOurs = stripBlock(existing);
|
|
142
|
+
// A block appended at top level closes whatever table preceded it, so the
|
|
143
|
+
// user's last section cannot swallow our entries.
|
|
144
|
+
const separator = withoutOurs === "" || withoutOurs.endsWith("\n") ? "" : "\n";
|
|
145
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
146
|
+
await writeFile(file, `${withoutOurs}${separator}${renderBlock(runner, node)}\n`);
|
|
147
|
+
|
|
148
|
+
const registry = registryPath(home);
|
|
149
|
+
await mkdir(path.dirname(registry), { recursive: true });
|
|
150
|
+
await writeForeignJson(registry, registerPlugin(registered, target),
|
|
151
|
+
{ readFile, writeFile, mkdir });
|
|
152
|
+
|
|
153
|
+
return { ok: true, changes: [target, file, registry], diagnostics: [] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function uninstallKimiPlugin({ home, keep = [] }) {
|
|
157
|
+
const file = configPath(home);
|
|
158
|
+
const existing = await readText(file, null);
|
|
159
|
+
const changes = [];
|
|
160
|
+
if (existing !== null) {
|
|
161
|
+
const stripped = stripBlock(existing);
|
|
162
|
+
if (stripped !== existing) changes.push(file);
|
|
163
|
+
await writeFile(file, stripped);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const registry = registryPath(home);
|
|
167
|
+
const loaded = await readJson(registry, null);
|
|
168
|
+
if (loaded !== null) {
|
|
169
|
+
const plugins = (loaded.plugins ?? []).filter(entry => entry.id !== PLUGIN_NAME);
|
|
170
|
+
if (plugins.length !== (loaded.plugins ?? []).length) changes.push(registry);
|
|
171
|
+
// The registry exists because a plugin was installed. Leaving an empty one
|
|
172
|
+
// behind is litter in a home that had no such file - measured after an
|
|
173
|
+
// uninstall that was otherwise clean. An empty registry and an absent one
|
|
174
|
+
// mean the same thing to this client.
|
|
175
|
+
if (plugins.length > 0) {
|
|
176
|
+
await writeForeignJson(registry, { ...loaded, plugins },
|
|
177
|
+
{ readFile, writeFile, mkdir });
|
|
178
|
+
} else {
|
|
179
|
+
await rm(registry, { force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Blank and absent are the same to this client, so there is nothing to lose
|
|
184
|
+
// by removing a config that holds nothing - and no record of who created it is
|
|
185
|
+
// needed, unlike a JSON settings file where `{}` can be the user's own.
|
|
186
|
+
await removeIfEmpty(configPath(home), { readFile, rm, isEmpty: blankText });
|
|
187
|
+
|
|
188
|
+
await removeInstalledTree(pluginPath(home), keep);
|
|
189
|
+
return { ok: true, changes, diagnostics: [] };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function detectKimi({ home, runner = defaultRunner() }) {
|
|
193
|
+
const source = await readText(configPath(home), "");
|
|
194
|
+
const installed = source.includes(BEGIN);
|
|
195
|
+
const registry = await readJson(registryPath(home), null);
|
|
196
|
+
const registered = (registry?.plugins ?? []).some(entry => entry.id === PLUGIN_NAME);
|
|
197
|
+
return { ok: true, changes: [], diagnostics: [
|
|
198
|
+
installed ? "acc hooks registered in config.toml" : "acc hooks not registered",
|
|
199
|
+
registered ? "acc plugin registered" : "acc plugin not registered",
|
|
200
|
+
await runnerExists(runner)
|
|
201
|
+
? `hook runner present at ${runner}`
|
|
202
|
+
: `hook runner MISSING at ${runner}; every hook would fail silently`,
|
|
203
|
+
] };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The paths an install would write, without writing them. Same helpers as the
|
|
208
|
+
* install, so a dry run cannot drift from what actually happens.
|
|
209
|
+
*/
|
|
210
|
+
export function planKimiInstall({ home }) {
|
|
211
|
+
return [
|
|
212
|
+
{ path: pluginPath(home), kind: "tree" },
|
|
213
|
+
{ path: configPath(home), kind: "merge" },
|
|
214
|
+
{ path: registryPath(home), kind: "merge" },
|
|
215
|
+
];
|
|
216
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
|
|
2
|
+
|
|
3
|
+
// The capability surface, documented in docs/ADAPTER_AUTHORING.md and measured
|
|
4
|
+
// per client in docs/CAPABILITIES.md. False is the default for every
|
|
5
|
+
// entry: an adapter earns a true value by implementing the method that backs it
|
|
6
|
+
// and by proving it in the conformance suite. Optimistic defaults are how a
|
|
7
|
+
// tool ends up claiming protection it cannot deliver.
|
|
8
|
+
export const CAPABILITY_SHAPE = Object.freeze({
|
|
9
|
+
lifecycle: ["sessionStart", "sessionResume", "sessionEnd", "heartbeat", "childSessions"],
|
|
10
|
+
context: ["startupInjection", "beforeTurnInjection", "safePointInjection"],
|
|
11
|
+
guards: ["beforeRead", "beforeWrite", "beforeShell"],
|
|
12
|
+
delivery: ["polling", "activeNotification", "wakeDormantSession"],
|
|
13
|
+
execution: ["launch", "resume", "terminate"],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Each true capability names the method that has to exist for it to be true.
|
|
17
|
+
const BACKING_METHOD = Object.freeze({
|
|
18
|
+
"lifecycle.sessionStart": "startSession",
|
|
19
|
+
"lifecycle.sessionResume": "resumeSession",
|
|
20
|
+
"lifecycle.sessionEnd": "endSession",
|
|
21
|
+
// A timer-driven event from the client, distinct from delivery.polling: it
|
|
22
|
+
// keeps presence fresh while the session is idle, which turn-driven hooks
|
|
23
|
+
// cannot do.
|
|
24
|
+
"lifecycle.heartbeat": "heartbeat",
|
|
25
|
+
"lifecycle.childSessions": "mapChildSession",
|
|
26
|
+
"context.startupInjection": "renderContext",
|
|
27
|
+
"context.beforeTurnInjection": "renderContext",
|
|
28
|
+
"context.safePointInjection": "renderContext",
|
|
29
|
+
"guards.beforeRead": "guardRead",
|
|
30
|
+
"guards.beforeWrite": "guardWrite",
|
|
31
|
+
"guards.beforeShell": "guardShell",
|
|
32
|
+
"delivery.polling": "poll",
|
|
33
|
+
"delivery.activeNotification": "notify",
|
|
34
|
+
"delivery.wakeDormantSession": "wake",
|
|
35
|
+
"execution.launch": "launch",
|
|
36
|
+
"execution.resume": "resumeProcess",
|
|
37
|
+
"execution.terminate": "terminate",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const BASE_METHODS = Object.freeze(["detect", "install", "uninstall", "doctor",
|
|
41
|
+
"normalizeHook", "renderContext"]);
|
|
42
|
+
|
|
43
|
+
function usage(message, details = {}) {
|
|
44
|
+
throw new AccError(EXIT.USAGE, message, details);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function assertCapabilities(declared = {}, implementation = {}) {
|
|
48
|
+
const resolved = {};
|
|
49
|
+
for (const [group, names] of Object.entries(CAPABILITY_SHAPE)) {
|
|
50
|
+
resolved[group] = Object.fromEntries(names.map(name => [name, false]));
|
|
51
|
+
}
|
|
52
|
+
for (const [group, values] of Object.entries(declared)) {
|
|
53
|
+
if (!Object.hasOwn(CAPABILITY_SHAPE, group)) {
|
|
54
|
+
usage(`unknown capability group: ${group}`, { group });
|
|
55
|
+
}
|
|
56
|
+
for (const [name, value] of Object.entries(values ?? {})) {
|
|
57
|
+
if (!CAPABILITY_SHAPE[group].includes(name)) {
|
|
58
|
+
usage(`unknown capability: ${group}.${name}`, { group, name });
|
|
59
|
+
}
|
|
60
|
+
if (typeof value !== "boolean") {
|
|
61
|
+
usage(`capability ${group}.${name} must be a boolean`, { group, name, value });
|
|
62
|
+
}
|
|
63
|
+
if (value === true) {
|
|
64
|
+
const method = BACKING_METHOD[`${group}.${name}`];
|
|
65
|
+
if (typeof implementation[method] !== "function") {
|
|
66
|
+
usage(`capability ${group}.${name} requires ${method}()`, { group, name, method });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
resolved[group][name] = value;
|
|
70
|
+
}
|
|
71
|
+
Object.freeze(resolved[group]);
|
|
72
|
+
}
|
|
73
|
+
for (const group of Object.keys(CAPABILITY_SHAPE)) Object.freeze(resolved[group]);
|
|
74
|
+
return Object.freeze(resolved);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function defineAdapter(manifest) {
|
|
78
|
+
if (typeof manifest?.id !== "string") usage("an adapter must declare an id");
|
|
79
|
+
assertPortableId(manifest.id, "adapter id");
|
|
80
|
+
if (typeof manifest.displayName !== "string" || manifest.displayName.trim() === "") {
|
|
81
|
+
usage("an adapter must declare a displayName", { id: manifest.id });
|
|
82
|
+
}
|
|
83
|
+
// Detection spawns this to decide whether the client is on the machine.
|
|
84
|
+
// Left undeclared it used to fall back to the adapter id, so `claude_code`
|
|
85
|
+
// and `gemini_cli` probed binaries that do not exist and were reported absent
|
|
86
|
+
// on every machine - `acc install` silently skipped half its clients.
|
|
87
|
+
const command = manifest.client?.command;
|
|
88
|
+
if (typeof command !== "string" || command.trim() === "") {
|
|
89
|
+
usage("an adapter must declare client.command, the binary its client installs",
|
|
90
|
+
{ id: manifest.id });
|
|
91
|
+
}
|
|
92
|
+
for (const method of BASE_METHODS) {
|
|
93
|
+
if (typeof manifest[method] !== "function") {
|
|
94
|
+
usage(`an adapter must implement ${method}()`, { id: manifest.id, method });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
...manifest,
|
|
99
|
+
capabilities: assertCapabilities(manifest.capabilities, manifest),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
const MARKER = "acc:owned";
|
|
4
|
+
// Ownership of single entries inside a container someone else also writes to.
|
|
5
|
+
// `enabledPlugins` in a Claude Code settings file holds every plugin the user
|
|
6
|
+
// has, so taking the whole key would destroy them - and giving it back on
|
|
7
|
+
// uninstall would destroy them again.
|
|
8
|
+
const ENTRY_MARKER = "acc:ownedEntries";
|
|
9
|
+
// Containers ACC had to create. Recorded separately from whole owned keys:
|
|
10
|
+
// creating one is permission to remove it when it ends up empty, not permission
|
|
11
|
+
// to take whatever the user has put in it since.
|
|
12
|
+
const CREATED_MARKER = "acc:createdContainers";
|
|
13
|
+
// The file itself, as opposed to a container inside it. Recorded at install
|
|
14
|
+
// because uninstall cannot tell afterwards: a settings file holding `{}` looks
|
|
15
|
+
// the same whether ACC made it or the user did, and deleting the user's would
|
|
16
|
+
// be the same overreach as taking a container they had put entries in.
|
|
17
|
+
const CREATED_FILE = "acc:createdFile";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Merge ACC entries into a user's config and record ownership, so uninstall can
|
|
21
|
+
* remove exactly what ACC added and nothing else.
|
|
22
|
+
*
|
|
23
|
+
* The ownership record is what makes uninstall safe: without it the only way to
|
|
24
|
+
* remove entries is to guess by shape, which is how a tool deletes a user's
|
|
25
|
+
* unrelated hook that happened to look similar.
|
|
26
|
+
*/
|
|
27
|
+
export function mergeOwnedConfig(existing, additions, { owner = MARKER } = {}) {
|
|
28
|
+
const merged = { ...existing };
|
|
29
|
+
const owned = new Set(existing?.[owner] ?? []);
|
|
30
|
+
for (const [key, value] of Object.entries(additions)) {
|
|
31
|
+
merged[key] = value;
|
|
32
|
+
owned.add(key);
|
|
33
|
+
}
|
|
34
|
+
merged[owner] = [...owned].sort();
|
|
35
|
+
return merged;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function removeOwnedConfig(existing, { owner = MARKER } = {}) {
|
|
39
|
+
const owned = new Set(existing?.[owner] ?? []);
|
|
40
|
+
const result = {};
|
|
41
|
+
for (const [key, value] of Object.entries(existing ?? {})) {
|
|
42
|
+
// Unrelated keys survive untouched, including keys a user added by hand
|
|
43
|
+
// under a name ACC also uses elsewhere.
|
|
44
|
+
if (key !== owner && !owned.has(key)) result[key] = value;
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ownedKeys(existing, { owner = MARKER } = {}) {
|
|
50
|
+
return [...(existing?.[owner] ?? [])];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Add entries inside containers ACC shares with the user.
|
|
55
|
+
*
|
|
56
|
+
* Records `[container, key]` pairs, so uninstall removes exactly the entries ACC
|
|
57
|
+
* added. A container ACC had to create is recorded too, but as a container it may
|
|
58
|
+
* clean up rather than as a key it owns: an empty one left behind is litter, and
|
|
59
|
+
* one the user has since put their own entries into is theirs.
|
|
60
|
+
*/
|
|
61
|
+
export function mergeOwnedEntries(existing, additions,
|
|
62
|
+
{ entryOwner = ENTRY_MARKER, createdOwner = CREATED_MARKER, createdFile } = {}) {
|
|
63
|
+
const merged = { ...existing };
|
|
64
|
+
if (createdFile === true) merged[CREATED_FILE] = true;
|
|
65
|
+
const entries = new Map((existing?.[entryOwner] ?? [])
|
|
66
|
+
.map(pair => [`${pair[0]}\u0000${pair[1]}`, pair]));
|
|
67
|
+
const created = new Set(existing?.[createdOwner] ?? []);
|
|
68
|
+
|
|
69
|
+
for (const [container, values] of Object.entries(additions)) {
|
|
70
|
+
if (!Object.hasOwn(existing ?? {}, container)) created.add(container);
|
|
71
|
+
merged[container] = { ...(existing?.[container] ?? {}) };
|
|
72
|
+
for (const [key, value] of Object.entries(values)) {
|
|
73
|
+
merged[container][key] = value;
|
|
74
|
+
entries.set(`${container}\u0000${key}`, [container, key]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
merged[entryOwner] = [...entries.values()]
|
|
79
|
+
.sort((left, right) => left.join("/").localeCompare(right.join("/")));
|
|
80
|
+
if (created.size > 0) merged[createdOwner] = [...created].sort();
|
|
81
|
+
return merged;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Remove only the entries ACC recorded adding, leaving every other one. */
|
|
85
|
+
export function removeOwnedEntries(existing,
|
|
86
|
+
{ owner = MARKER, entryOwner = ENTRY_MARKER, createdOwner = CREATED_MARKER } = {}) {
|
|
87
|
+
const result = { ...(existing ?? {}) };
|
|
88
|
+
for (const [container, key] of result[entryOwner] ?? []) {
|
|
89
|
+
if (result[container] === null || typeof result[container] !== "object") continue;
|
|
90
|
+
const remaining = { ...result[container] };
|
|
91
|
+
delete remaining[key];
|
|
92
|
+
result[container] = remaining;
|
|
93
|
+
}
|
|
94
|
+
// A container ACC created goes only if nothing is left in it. Taking it
|
|
95
|
+
// outright would delete entries the user added after the install - the exact
|
|
96
|
+
// loss that recording ownership per entry exists to prevent.
|
|
97
|
+
for (const container of result[createdOwner] ?? []) {
|
|
98
|
+
const value = result[container];
|
|
99
|
+
if (value !== null && typeof value === "object" && Object.keys(value).length === 0) {
|
|
100
|
+
delete result[container];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
delete result[entryOwner];
|
|
104
|
+
delete result[createdOwner];
|
|
105
|
+
delete result[CREATED_FILE];
|
|
106
|
+
return removeOwnedConfig(result, { owner });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function ownedEntries(existing, { entryOwner = ENTRY_MARKER } = {}) {
|
|
110
|
+
return (existing?.[entryOwner] ?? []).map(pair => [...pair]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write JSON back into someone else's file, in that file's own style.
|
|
115
|
+
*
|
|
116
|
+
* ACC re-emits what it edits, and re-emitting with a fixed style rewrites bytes
|
|
117
|
+
* it was not asked to touch. Measured after an install and uninstall that
|
|
118
|
+
* changed nothing else: three clients' configs came back one byte longer, a
|
|
119
|
+
* trailing newline appended to files that had none. It is the same defect that
|
|
120
|
+
* was found once in Claude Code's registries and fixed there by hand - fixed for
|
|
121
|
+
* two files rather than for the shape, so the other three kept doing it.
|
|
122
|
+
*
|
|
123
|
+
* The style is read from the file rather than declared per client: what a client
|
|
124
|
+
* writes is a fact about the client, and asking the file cannot go stale.
|
|
125
|
+
* Absent file, absent style: a file ACC creates is its own, and gets the
|
|
126
|
+
* conventional trailing newline.
|
|
127
|
+
*/
|
|
128
|
+
export function jsonStyleOf(text) {
|
|
129
|
+
if (typeof text !== "string") return { indent: 2, trailingNewline: true };
|
|
130
|
+
// The whitespace itself, not a count of it. `JSON.stringify` takes a string
|
|
131
|
+
// for its indent, so a tab-indented file stays tab-indented; measuring the
|
|
132
|
+
// length instead turned one tab into one space and reformatted every line of
|
|
133
|
+
// a file this exists to leave alone. Matched without `\s`, which would span
|
|
134
|
+
// the blank line before an indented one and report its own newline as indent.
|
|
135
|
+
const indented = /\n([ \t]+)\S/.exec(text);
|
|
136
|
+
// A file written on one line was written that way on purpose, and expanding it
|
|
137
|
+
// is the same unasked-for rewrite as changing its indent.
|
|
138
|
+
const oneLine = !text.trimEnd().includes("\n");
|
|
139
|
+
// Ten is what `JSON.stringify` itself honours; more is silently truncated,
|
|
140
|
+
// and truncating here keeps what is written equal to what was measured.
|
|
141
|
+
return {
|
|
142
|
+
indent: oneLine ? 0 : (indented === null ? 2 : indented[1].slice(0, 10)),
|
|
143
|
+
trailingNewline: text.endsWith("\n"),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Write a file ACC did not create, keeping the shape of what was there.
|
|
149
|
+
*
|
|
150
|
+
* Unchanged content is not rewritten at all, so a second install touches
|
|
151
|
+
* nothing. A file that does not exist yet is ACC's to create, and gets the
|
|
152
|
+
* conventional trailing newline.
|
|
153
|
+
*/
|
|
154
|
+
export async function writeForeignJson(file, value, { readFile, writeFile, mkdir }) {
|
|
155
|
+
const current = await readFile(file, "utf8").catch(() => null);
|
|
156
|
+
const text = formatJsonAs(value, jsonStyleOf(current));
|
|
157
|
+
if (current === text) return false;
|
|
158
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
159
|
+
await writeFile(file, text);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function formatJsonAs(value, style) {
|
|
164
|
+
const measured = style?.indent;
|
|
165
|
+
const indent = typeof measured === "string" && measured !== "" ? measured
|
|
166
|
+
: (Number.isInteger(measured) && measured >= 0 ? measured : 2);
|
|
167
|
+
const text = JSON.stringify(value, null, indent);
|
|
168
|
+
return style?.trailingNewline === false ? text : `${text}\n`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Remove a file ACC wrote once nothing is left in it.
|
|
173
|
+
*
|
|
174
|
+
* The same rule as a container ACC created: what is empty was ACC's alone, and
|
|
175
|
+
* leaving it is litter in a home that did not have it. Measured after an install
|
|
176
|
+
* and uninstall in a home that started with nothing - four files left behind,
|
|
177
|
+
* two of them empty, one a marketplace manifest naming ACC's own marketplace.
|
|
178
|
+
*
|
|
179
|
+
* It was already fixed for two registries. The fixture that proved it seeded
|
|
180
|
+
* every config first, so the branch where ACC creates the file was never taken:
|
|
181
|
+
* the instance was fixed and the shape was not.
|
|
182
|
+
*/
|
|
183
|
+
export function acccreatedFile(value) {
|
|
184
|
+
return value?.[CREATED_FILE] === true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function removeIfEmpty(file, { readFile, rm, isEmpty, created = true }) {
|
|
188
|
+
if (created !== true) return false;
|
|
189
|
+
const current = await readFile(file, "utf8").catch(() => null);
|
|
190
|
+
if (current === null) return false;
|
|
191
|
+
let empty;
|
|
192
|
+
try {
|
|
193
|
+
empty = isEmpty(current);
|
|
194
|
+
} catch {
|
|
195
|
+
// Unparseable is not empty. A file nobody can read is the user's to fix,
|
|
196
|
+
// and deleting it would take whatever it was meant to hold.
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
if (!empty) return false;
|
|
200
|
+
await rm(file, { force: true });
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Nothing but whitespace, for the clients whose config is TOML. */
|
|
205
|
+
export const blankText = text => text.trim() === "";
|
|
206
|
+
|
|
207
|
+
/** An object with no keys, or only the ones named as ACC's own. */
|
|
208
|
+
export const blankJson = (ours = []) => text => {
|
|
209
|
+
const value = JSON.parse(text);
|
|
210
|
+
if (value === null || typeof value !== "object") return false;
|
|
211
|
+
return Object.entries(value)
|
|
212
|
+
.every(([key, held]) => ours.includes(key)
|
|
213
|
+
|| (Array.isArray(held) ? held.length === 0
|
|
214
|
+
: held !== null && typeof held === "object" && Object.keys(held).length === 0));
|
|
215
|
+
};
|