@comento/pota-harness 0.0.0-stage → 1.0.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/.agents/workflows/pota-doctor.md +16 -0
- package/.agents/workflows/pota-install.md +15 -0
- package/.agents/workflows/pota-memory.md +17 -0
- package/.agents/workflows/pota-plan.md +22 -0
- package/.agents/workflows/pota-review.md +16 -0
- package/.agents/workflows/pota-toolcheck.md +9 -0
- package/.agents/workflows/pota-uninstall.md +9 -0
- package/.agents/workflows/pota-upgrade.md +15 -0
- package/.agents/workflows/pota-work.md +24 -0
- package/.claude/agents/explorer.md +7 -0
- package/.claude/agents/worker.md +7 -0
- package/.claude/commands/pota-doctor.md +5 -0
- package/.claude/commands/pota-install.md +9 -0
- package/.claude/commands/pota-memory.md +5 -0
- package/.claude/commands/pota-plan.md +5 -0
- package/.claude/commands/pota-review.md +5 -0
- package/.claude/commands/pota-toolcheck.md +7 -0
- package/.claude/commands/pota-uninstall.md +5 -0
- package/.claude/commands/pota-upgrade.md +9 -0
- package/.claude/commands/pota-work.md +7 -0
- package/.codex-plugin/plugin.json +33 -0
- package/LICENSE +21 -0
- package/README.de.md +166 -0
- package/README.es.md +166 -0
- package/README.fr.md +166 -0
- package/README.ja.md +166 -0
- package/README.ko.md +184 -0
- package/README.md +191 -2
- package/README.pt.md +166 -0
- package/README.zh.md +166 -0
- package/agents/explorer.md +36 -0
- package/agents/worker.md +45 -0
- package/commands/pota-doctor.md +28 -0
- package/commands/pota-install.md +29 -0
- package/commands/pota-memory.md +17 -0
- package/commands/pota-plan.md +22 -0
- package/commands/pota-review.md +16 -0
- package/commands/pota-toolcheck.md +17 -0
- package/commands/pota-uninstall.md +27 -0
- package/commands/pota-upgrade.md +26 -0
- package/commands/pota-work.md +24 -0
- package/docs/model-guidance/README.md +67 -0
- package/docs/model-guidance/claude.md +49 -0
- package/docs/model-guidance/codex.md +68 -0
- package/hooks/hooks.json +65 -0
- package/package.json +47 -4
- package/scripts/pota +14 -0
- package/scripts/pota-auto-upgrade.mjs +342 -0
- package/scripts/pota-cleanup.mjs +194 -0
- package/scripts/pota-goal-cli.mjs +337 -0
- package/scripts/pota-goal-evidence.mjs +186 -0
- package/scripts/pota-goal-history.mjs +316 -0
- package/scripts/pota-goal-operations-evidence.mjs +213 -0
- package/scripts/pota-goal-operations-mutations.mjs +97 -0
- package/scripts/pota-goal-operations.mjs +281 -0
- package/scripts/pota-goal-policy.mjs +62 -0
- package/scripts/pota-goal-state-validation.mjs +361 -0
- package/scripts/pota-goal-state.mjs +136 -0
- package/scripts/pota-goal.mjs +69 -0
- package/scripts/pota-index.mjs +1052 -0
- package/scripts/pota-lifecycle-claude.mjs +173 -0
- package/scripts/pota-lifecycle-commands.mjs +418 -0
- package/scripts/pota-lifecycle-config.mjs +153 -0
- package/scripts/pota-lifecycle-hosts.mjs +98 -0
- package/scripts/pota-lifecycle-integrity.mjs +272 -0
- package/scripts/pota-lifecycle-layout.mjs +52 -0
- package/scripts/pota-lifecycle-lock.mjs +205 -0
- package/scripts/pota-lifecycle-mirror.mjs +83 -0
- package/scripts/pota-lifecycle-staging.mjs +156 -0
- package/scripts/pota-lifecycle-transaction-journal.mjs +244 -0
- package/scripts/pota-lifecycle-transaction-targets.mjs +119 -0
- package/scripts/pota-lifecycle-transaction.mjs +12 -0
- package/scripts/pota-lifecycle.mjs +244 -0
- package/scripts/pota-paths.mjs +190 -0
- package/scripts/pota-safe-fs.mjs +277 -0
- package/scripts/pota-state-context.mjs +281 -0
- package/scripts/pota-state-hook.mjs +26 -0
- package/scripts/pota-state-init.mjs +80 -0
- package/scripts/pota-state.mjs +245 -0
- package/scripts/pota-toolcheck-detect.mjs +229 -0
- package/scripts/pota-toolcheck-runtime.mjs +178 -0
- package/scripts/pota-toolcheck.mjs +221 -0
- package/scripts/pota-update-state.mjs +219 -0
- package/scripts/pota.mjs +200 -0
- package/skills/pota-memory/SKILL.md +52 -0
- package/skills/pota-plan/SKILL.md +66 -0
- package/skills/pota-plan/delegation.md +65 -0
- package/skills/pota-plan/heavy-path.md +28 -0
- package/skills/pota-plan/planning-rules.md +78 -0
- package/skills/pota-review/SKILL.md +56 -0
- package/skills/pota-work/SKILL.md +71 -0
- package/skills/pota-work/delegation.md +65 -0
- package/skills/pota-work/heavy-path.md +18 -0
- package/skills/pota-work/recovery.md +29 -0
- package/skills/pota-work/verification.md +79 -0
- package/templates/MEMORY.md +12 -0
- package/templates/PLAN.md +9 -0
- package/templates/domain-memory.md +22 -0
- package/templates/work-plan.md +177 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { quoteShellArg, renderMirrorFile, writeLocator } from "./pota-lifecycle-mirror.mjs";
|
|
5
|
+
|
|
6
|
+
// Claude Code loads a plugin's default hooks/hooks.json and merges any manifest hooks, and it only expands
|
|
7
|
+
// ${CLAUDE_PLUGIN_ROOT}. The shared package hooks use Codex's ${PLUGIN_ROOT}, so Claude gets a separate thin
|
|
8
|
+
// plugin whose hooks call the installed canonical runtime by absolute path. Commands and agents stay in the
|
|
9
|
+
// existing ~/.claude mirrors so they are not listed twice.
|
|
10
|
+
export async function stageClaudePlugin({
|
|
11
|
+
stageRoot, runtimeStage, destinationRoot, runtimeRoot, marketplaceName, pluginName,
|
|
12
|
+
releaseVersion, skillNames, platform = process.platform,
|
|
13
|
+
}) {
|
|
14
|
+
const staged = join(stageRoot, "claude-plugin");
|
|
15
|
+
const pluginDir = join(staged, pluginName);
|
|
16
|
+
const packageJson = JSON.parse(await readFile(join(runtimeStage, "package.json"), "utf8"));
|
|
17
|
+
const description = packageJson.description ?? "Pota agent harness";
|
|
18
|
+
|
|
19
|
+
await writeJson(join(staged, ".claude-plugin", "marketplace.json"), {
|
|
20
|
+
name: marketplaceName,
|
|
21
|
+
description: "Local Pota marketplace managed by `pota install`",
|
|
22
|
+
owner: { name: packageJson.author?.name ?? "Pota" },
|
|
23
|
+
plugins: [{ name: pluginName, source: `./${pluginName}`, description, version: releaseVersion }],
|
|
24
|
+
});
|
|
25
|
+
await writeJson(join(pluginDir, ".claude-plugin", "plugin.json"), {
|
|
26
|
+
name: pluginName,
|
|
27
|
+
version: releaseVersion,
|
|
28
|
+
description,
|
|
29
|
+
author: { name: packageJson.author?.name ?? "Pota" },
|
|
30
|
+
...(packageJson.homepage ? { homepage: packageJson.homepage } : {}),
|
|
31
|
+
...(packageJson.license ? { license: packageJson.license } : {}),
|
|
32
|
+
});
|
|
33
|
+
await writeJson(join(pluginDir, "hooks", "hooks.json"), await claudeHooks(runtimeStage, runtimeRoot, platform));
|
|
34
|
+
|
|
35
|
+
for (const name of skillNames) {
|
|
36
|
+
const skillStage = join(pluginDir, "skills", name);
|
|
37
|
+
await cp(join(runtimeStage, "skills", name), skillStage, { recursive: true });
|
|
38
|
+
const locatorPath = join(destinationRoot, pluginName, "skills", name, "references", "pota-runtime.json");
|
|
39
|
+
await renderMirrorFile(join(runtimeStage, "skills", name, "SKILL.md"), join(skillStage, "SKILL.md"), {
|
|
40
|
+
locatorPath, runtimeRoot, includeLocatorNotice: true,
|
|
41
|
+
});
|
|
42
|
+
await writeLocator(join(skillStage, "references", "pota-runtime.json"), runtimeRoot);
|
|
43
|
+
}
|
|
44
|
+
return staged;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function claudeHooks(runtimeStage, runtimeRoot, platform) {
|
|
48
|
+
const source = JSON.parse(await readFile(join(runtimeStage, "hooks", "hooks.json"), "utf8"));
|
|
49
|
+
const stateScript = quoteShellArg(join(runtimeRoot, "scripts", "pota-state.mjs"), platform);
|
|
50
|
+
const hooks = {};
|
|
51
|
+
for (const [eventName, groups] of Object.entries(source.hooks ?? {})) {
|
|
52
|
+
hooks[eventName] = groups.map((group) => ({
|
|
53
|
+
...group,
|
|
54
|
+
hooks: (group.hooks ?? []).map((handler) => {
|
|
55
|
+
const event = String(handler.command ?? "").match(/pota-state\.mjs"?\s+([a-z-]+)\s*$/)?.[1];
|
|
56
|
+
if (!event) throw new Error(`Unsupported Pota hook command for Claude Code: ${handler.command}`);
|
|
57
|
+
return { ...handler, command: `node ${stateScript} ${event}` };
|
|
58
|
+
}),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
return { hooks };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function writeJson(path, value) {
|
|
65
|
+
await mkdir(dirname(path), { recursive: true });
|
|
66
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Registration goes through the documented `claude plugin` CLI. CLAUDE_CONFIG_DIR is only set when the caller
|
|
70
|
+
// overrode the Claude home, because setting it for the default home would move Claude's global config file.
|
|
71
|
+
// A registration that points at `marketplaceRoot` is Pota's own; any other directory stays foreign.
|
|
72
|
+
export function createClaudeRegistration({
|
|
73
|
+
claudeHome, claudeHomeOverridden, marketplaceRoot, marketplaceName, pluginName, releaseVersion,
|
|
74
|
+
}) {
|
|
75
|
+
const cli = process.env.POTA_CLAUDE_CLI ?? "claude";
|
|
76
|
+
const pluginId = `${pluginName}@${marketplaceName}`;
|
|
77
|
+
const env = claudeHomeOverridden ? { ...process.env, CLAUDE_CONFIG_DIR: claudeHome } : process.env;
|
|
78
|
+
|
|
79
|
+
// A .mjs/.js override runs through the current Node binary so tests can supply a portable stand-in CLI.
|
|
80
|
+
function run(args) {
|
|
81
|
+
if (/\.m?js$/.test(cli)) return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", env, timeout: 120_000 });
|
|
82
|
+
return spawnSync(cli, args, { encoding: "utf8", env, timeout: 120_000, shell: process.platform === "win32" });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function available() {
|
|
86
|
+
if (cli === "none") return false;
|
|
87
|
+
const result = run(["--version"]);
|
|
88
|
+
return !result.error && result.status === 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function json(args) {
|
|
92
|
+
const result = run([...args, "--json"]);
|
|
93
|
+
if (result.error || result.status !== 0) {
|
|
94
|
+
throw new Error(`claude ${args.join(" ")} failed: ${(result.stderr || result.stdout || result.error?.message || "").trim().split("\n").at(-1)}`);
|
|
95
|
+
}
|
|
96
|
+
const text = result.stdout.trim();
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(text);
|
|
99
|
+
} catch {
|
|
100
|
+
return JSON.parse(text.split("\n").filter(Boolean).at(-1));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ownsMarketplace = (entry) => entry?.name === marketplaceName
|
|
105
|
+
&& typeof entry.path === "string" && resolve(entry.path) === resolve(marketplaceRoot);
|
|
106
|
+
const ownedPlugins = (list) => (Array.isArray(list) ? list : [])
|
|
107
|
+
.filter((entry) => entry?.id === pluginId && entry.scope === "user");
|
|
108
|
+
|
|
109
|
+
function status({ checked = false } = {}) {
|
|
110
|
+
if (!checked && !available()) return { available: false, marketplace: false, installed: false, enabled: false };
|
|
111
|
+
const marketplace = json(["plugin", "marketplace", "list"]).find((entry) => entry?.name === marketplaceName) ?? null;
|
|
112
|
+
const plugin = ownedPlugins(json(["plugin", "list"]))[0] ?? null;
|
|
113
|
+
return {
|
|
114
|
+
available: true,
|
|
115
|
+
marketplace: ownsMarketplace(marketplace),
|
|
116
|
+
foreignMarketplace: Boolean(marketplace) && !ownsMarketplace(marketplace),
|
|
117
|
+
installed: Boolean(plugin),
|
|
118
|
+
enabled: Boolean(plugin?.enabled),
|
|
119
|
+
version: plugin?.version,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function register() {
|
|
124
|
+
if (!available()) return { registered: false, cliMissing: true, reason: "claude CLI not found" };
|
|
125
|
+
const current = status({ checked: true });
|
|
126
|
+
if (current.foreignMarketplace) {
|
|
127
|
+
return { registered: false, reason: `a different Claude Code marketplace named "${marketplaceName}" already exists` };
|
|
128
|
+
}
|
|
129
|
+
if (!current.marketplace) json(["plugin", "marketplace", "add", marketplaceRoot, "--scope", "user"]);
|
|
130
|
+
// A fresh install copies the current marketplace version into Claude Code's plugin cache.
|
|
131
|
+
if (!current.installed) {
|
|
132
|
+
json(["plugin", "install", pluginId, "--scope", "user"]);
|
|
133
|
+
return { registered: true };
|
|
134
|
+
}
|
|
135
|
+
if (!current.enabled) json(["plugin", "enable", pluginId, "--scope", "user"]);
|
|
136
|
+
if (current.version === releaseVersion) return { registered: true };
|
|
137
|
+
return { registered: true, cacheRefresh: refreshCache(current.version) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// An existing install keeps serving its cached copy until `plugin update` replaces it. The update is only
|
|
141
|
+
// requested when the cached version differs, so re-running install on a current cache stays a no-op.
|
|
142
|
+
function refreshCache(cachedVersion) {
|
|
143
|
+
try {
|
|
144
|
+
json(["plugin", "marketplace", "update", marketplaceName]);
|
|
145
|
+
} catch {
|
|
146
|
+
// A directory marketplace needs no refresh; `plugin update` below still reads the current files.
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
json(["plugin", "update", pluginId, "--scope", "user"]);
|
|
150
|
+
// Without a reported version there is nothing to verify; the update itself is the best available signal.
|
|
151
|
+
if (cachedVersion === undefined) return { refreshed: true };
|
|
152
|
+
const version = ownedPlugins(json(["plugin", "list"]))[0]?.version;
|
|
153
|
+
if (version !== releaseVersion) {
|
|
154
|
+
return { refreshed: false, reason: `${pluginId} is still at ${version ?? "an unknown version"} (expected ${releaseVersion})` };
|
|
155
|
+
}
|
|
156
|
+
return { refreshed: true };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return { refreshed: false, reason: error.message };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function unregister() {
|
|
163
|
+
if (!available()) return { unregistered: false, reason: "claude CLI not found" };
|
|
164
|
+
const current = status({ checked: true });
|
|
165
|
+
// Only remove a registration that points at Pota's own marketplace directory.
|
|
166
|
+
if (!current.marketplace) return { unregistered: false, reason: "no Pota-owned Claude Code marketplace is registered" };
|
|
167
|
+
if (current.installed) json(["plugin", "uninstall", pluginId, "--scope", "user"]);
|
|
168
|
+
json(["plugin", "marketplace", "remove", marketplaceName]);
|
|
169
|
+
return { unregistered: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { available, register, status, unregister, pluginId };
|
|
173
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
5
|
+
import { hasSetting, readSharedConfigSnapshot, removePotaConfig, splitSections } from "./pota-lifecycle-config.mjs";
|
|
6
|
+
import {
|
|
7
|
+
installedSkillLocatorPath, validRuntimeLocator, validateAntigravitySkills,
|
|
8
|
+
} from "./pota-lifecycle-mirror.mjs";
|
|
9
|
+
import { createClaudeRegistration } from "./pota-lifecycle-claude.mjs";
|
|
10
|
+
import { HOSTS, hostOfTarget, isHostSelection } from "./pota-lifecycle-hosts.mjs";
|
|
11
|
+
import {
|
|
12
|
+
OWNERSHIP_SCHEMA_VERSION, RETAINED_RUNTIME_GRACE_MS, RETAINED_RUNTIME_IDS, RETAINED_RUNTIME_LIMIT,
|
|
13
|
+
compareVersions,
|
|
14
|
+
} from "./pota-lifecycle-integrity.mjs";
|
|
15
|
+
import { AUTO_UPGRADE_EXIT } from "./pota-update-state.mjs";
|
|
16
|
+
|
|
17
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
18
|
+
|
|
19
|
+
export function createCommands(context) {
|
|
20
|
+
const {
|
|
21
|
+
antigravityCliHome, antigravityHome, claudeHome, claudeHomeOverridden, claudePluginMarketplaceRoot,
|
|
22
|
+
claudeRuntimeLocatorPath, codexCacheRoot, codexHome, codexRuntimeRoot, marketplaceName, configPath,
|
|
23
|
+
installRoot, installedPluginRoot, layout,
|
|
24
|
+
marketplaceJsonPath, ownershipPath, phases, pluginName, pluginRoot,
|
|
25
|
+
pruneUninstallScaffolds, releaseVersion, runtimeRoot, skillNames, t, transaction,
|
|
26
|
+
staging, integrity,
|
|
27
|
+
} = context;
|
|
28
|
+
const {
|
|
29
|
+
createTransaction, finishTransaction, recoverInterruptedTransaction,
|
|
30
|
+
removeManagedTarget, replaceTarget, rollback,
|
|
31
|
+
} = transaction;
|
|
32
|
+
const { stageInstall } = staging;
|
|
33
|
+
const claudeRegistration = createClaudeRegistration({
|
|
34
|
+
claudeHome, claudeHomeOverridden, marketplaceRoot: claudePluginMarketplaceRoot,
|
|
35
|
+
marketplaceName, pluginName, releaseVersion,
|
|
36
|
+
});
|
|
37
|
+
const {
|
|
38
|
+
assertOwnershipDigests,
|
|
39
|
+
assertTargetsReplaceable, ownershipDigestsValid, readOwnership, targetDigest,
|
|
40
|
+
targetRecord, validateOwnershipShape, withoutMissingRetained,
|
|
41
|
+
} = integrity;
|
|
42
|
+
// `selection` is the --hosts value or the prompt answer; without one an upgrade keeps the recorded hosts and a first
|
|
43
|
+
// install takes every host. `auto` is `upgrade --auto` from the auto-upgrade runner. `allowDowngrade` lets this CLI
|
|
44
|
+
// replace an installation of a newer version.
|
|
45
|
+
async function install(mode, { hosts: selection = null, auto = false, allowDowngrade = false } = {}) {
|
|
46
|
+
if (isInsidePath(installRoot, pluginRoot) || isInsidePath(codexCacheRoot, pluginRoot)) {
|
|
47
|
+
throw new Error("Run install or upgrade from the Pota package/global source, not from the installed cache.");
|
|
48
|
+
}
|
|
49
|
+
await recoverInterruptedTransaction();
|
|
50
|
+
// Checked under the lifecycle lock: an automatic upgrade only moves an existing installation forward, so a runner
|
|
51
|
+
// that started before an uninstall finished installs nothing.
|
|
52
|
+
if (auto && (await readJson(ownershipPath).catch(() => null))?.schemaVersion !== OWNERSHIP_SCHEMA_VERSION) {
|
|
53
|
+
throw new Error("auto-upgrade requires an existing installation");
|
|
54
|
+
}
|
|
55
|
+
// An older CLI (an old global install, an old npx cache) would otherwise move a newer installation back, and the
|
|
56
|
+
// auto-upgrade runner would move it forward again.
|
|
57
|
+
const installedVersion = await recordedVersion();
|
|
58
|
+
if (!allowDowngrade && installedVersion && compareVersions(installedVersion, releaseVersion) > 0) {
|
|
59
|
+
throw new Error(`Installed Pota ${installedVersion} is newer than this CLI ${releaseVersion}; run the newer CLI, or pass --allow-downgrade to install ${releaseVersion}`);
|
|
60
|
+
}
|
|
61
|
+
const transaction = await createTransaction();
|
|
62
|
+
let unregisteredClaude = false;
|
|
63
|
+
let hosts = [...HOSTS];
|
|
64
|
+
let droppedHosts = false;
|
|
65
|
+
try {
|
|
66
|
+
let ownership = await readOwnership();
|
|
67
|
+
if (ownership) validateOwnershipShape(ownership);
|
|
68
|
+
// A retained runtime that is already gone needs no removal and is no longer recorded.
|
|
69
|
+
if (ownership) ownership = await withoutMissingRetained(ownership);
|
|
70
|
+
hosts = selection ?? (ownership ? ownership.hosts : hosts);
|
|
71
|
+
droppedHosts = Boolean(ownership) && ownership.hosts.some((host) => !hosts.includes(host));
|
|
72
|
+
const targets = await stageInstall(transaction.stageRoot, hosts);
|
|
73
|
+
if (process.env.POTA_TEST_CRASH_AFTER_STAGE === "1") process.exit(87);
|
|
74
|
+
let retained = [];
|
|
75
|
+
if (ownership) {
|
|
76
|
+
await assertOwnershipDigests(ownership);
|
|
77
|
+
const retention = planRetention(ownership, new Date(), hosts);
|
|
78
|
+
targets.push(...retention.removals);
|
|
79
|
+
retained = retention.retained;
|
|
80
|
+
targets.push(...await deselectedHostTargets(ownership, hosts, transaction));
|
|
81
|
+
}
|
|
82
|
+
await assertTargetsReplaceable(targets, ownership);
|
|
83
|
+
// Like uninstall, a deselected Claude Code plugin is unregistered before its files are removed; a rollback
|
|
84
|
+
// restores the files and then the registration.
|
|
85
|
+
if (ownership?.hosts?.includes("claude") && !hosts.includes("claude")) {
|
|
86
|
+
unregisteredClaude = claudeRegistration.unregister().unregistered;
|
|
87
|
+
}
|
|
88
|
+
for (const phase of phases) {
|
|
89
|
+
for (const target of targets.filter((candidate) => candidate.phase === phase)) {
|
|
90
|
+
if (target.operation === "remove") await removeManagedTarget(target, transaction);
|
|
91
|
+
else await replaceTarget(target, transaction);
|
|
92
|
+
}
|
|
93
|
+
injectFailure(`after-${phase}`);
|
|
94
|
+
if (process.env.POTA_TEST_CRASH_AFTER_PHASE === phase) process.exit(88);
|
|
95
|
+
}
|
|
96
|
+
const records = [];
|
|
97
|
+
for (const target of targets.filter((candidate) => candidate.operation !== "remove" && !candidate.deselected)) records.push(await targetRecord(target));
|
|
98
|
+
const nextOwnership = {
|
|
99
|
+
schemaVersion: OWNERSHIP_SCHEMA_VERSION,
|
|
100
|
+
plugin: pluginName,
|
|
101
|
+
version: releaseVersion,
|
|
102
|
+
transactionId: transaction.id,
|
|
103
|
+
installedAt: new Date().toISOString(),
|
|
104
|
+
origin: await exists(join(pluginRoot, ".git")) ? "checkout" : "package",
|
|
105
|
+
hosts: [...hosts],
|
|
106
|
+
targets: records,
|
|
107
|
+
retained,
|
|
108
|
+
};
|
|
109
|
+
const ownershipStage = join(transaction.stageRoot, "lifecycle.json");
|
|
110
|
+
await mkdir(dirname(ownershipStage), { recursive: true });
|
|
111
|
+
await writeFile(ownershipStage, `${JSON.stringify(nextOwnership, null, 2)}\n`);
|
|
112
|
+
await replaceTarget({ id: "ownership", phase: "manifest", path: ownershipPath, staged: ownershipStage, type: "file" }, transaction);
|
|
113
|
+
await finishTransaction(transaction);
|
|
114
|
+
console.log(t(mode === "upgrade" ? "upgradeComplete" : "installComplete"));
|
|
115
|
+
console.log(`Pota runtime: ${runtimeRoot}`);
|
|
116
|
+
if (nextOwnership.hosts.includes("codex")) {
|
|
117
|
+
console.log(`Codex plugin source: ${installedPluginRoot}`);
|
|
118
|
+
console.log(`Codex versioned runtime: ${codexRuntimeRoot}`);
|
|
119
|
+
}
|
|
120
|
+
console.log(`Ownership manifest: ${ownershipPath}`);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (transaction.state !== "committed") {
|
|
123
|
+
await rollback(transaction);
|
|
124
|
+
if (unregisteredClaude) {
|
|
125
|
+
try { claudeRegistration.register(); } catch (registrationError) { console.error(`Claude Code plugin re-registration failed: ${registrationError.message}`); }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
if (hosts.includes("claude")) reportClaudeRegistration({ auto });
|
|
131
|
+
// Removing a host can leave its empty scaffolds (Codex marketplace and cache directories); only empty ones go.
|
|
132
|
+
if (droppedHosts) await pruneUninstallScaffolds();
|
|
133
|
+
injectFailure("after-commit");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The version the manifest records, or null when there is none or it is unreadable (the manifest checks that follow
|
|
137
|
+
// report that).
|
|
138
|
+
async function recordedVersion() {
|
|
139
|
+
const version = (await readJson(ownershipPath).catch(() => null))?.version;
|
|
140
|
+
return typeof version === "string" && VERSION_PATTERN.test(version) ? version : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Records of hosts that are no longer selected are removed under their own ids, so assertTargetsReplaceable compares
|
|
144
|
+
// them with the manifest and refuses modified ones. The shared config keeps everything but Pota's sections, as in
|
|
145
|
+
// uninstall, and a Codex cache runtime from another version is removed through its journal-only id.
|
|
146
|
+
async function deselectedHostTargets(ownership, hosts, transaction) {
|
|
147
|
+
const removals = [];
|
|
148
|
+
for (const record of ownership.targets) {
|
|
149
|
+
const host = hostOfTarget(record.id);
|
|
150
|
+
if (host === "core" || hosts.includes(host)) continue;
|
|
151
|
+
if (record.type === "config") {
|
|
152
|
+
if (!(await exists(record.path))) continue;
|
|
153
|
+
const snapshot = await readSharedConfigSnapshot(record.path);
|
|
154
|
+
const staged = join(transaction.stageRoot, "deselected-config.toml");
|
|
155
|
+
await mkdir(dirname(staged), { recursive: true });
|
|
156
|
+
await writeFile(staged, removePotaConfig(snapshot.text));
|
|
157
|
+
removals.push({ id: record.id, phase: record.phase, path: record.path, staged, type: record.type, configSourceDigest: snapshot.digest, deselected: true });
|
|
158
|
+
} else if (record.id === "codex-runtime" && record.path !== codexRuntimeRoot) {
|
|
159
|
+
removals.push({ id: "previous-codex-runtime", phase: "cleanup", path: record.path, type: record.type, operation: "remove", ownedBy: record.id });
|
|
160
|
+
} else {
|
|
161
|
+
removals.push({ id: record.id, phase: record.phase, path: record.path, type: record.type, operation: "remove" });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return removals;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function hostsOf(ownership) {
|
|
168
|
+
return isHostSelection(ownership?.hosts) ? ownership.hosts : [...HOSTS];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A version change retains the previous runtimes for sessions that still run them, except a Codex cache copy that is
|
|
172
|
+
// not older than the release: Codex loads the highest cached version, so that copy would replace the release. Retained
|
|
173
|
+
// runtimes past the grace period, beyond the per-id limit (oldest first), or no longer older than a Codex release are
|
|
174
|
+
// removed in cleanup; the release's own retained runtime becomes the installed target again.
|
|
175
|
+
// Without Codex in the selection there is no codex-runtime record to retain, and Codex runtimes retained earlier are
|
|
176
|
+
// removed in this run.
|
|
177
|
+
function planRetention(ownership, now, hosts) {
|
|
178
|
+
const removals = [];
|
|
179
|
+
const candidates = [];
|
|
180
|
+
const retiredAt = now.toISOString();
|
|
181
|
+
const keepsCodex = hosts.includes("codex");
|
|
182
|
+
if (ownership.version !== releaseVersion) {
|
|
183
|
+
for (const id of RETAINED_RUNTIME_IDS) {
|
|
184
|
+
const record = ownership.targets.find((candidate) => candidate.id === id);
|
|
185
|
+
// A codex-runtime record of a deselected Codex is removed by deselectedHostTargets.
|
|
186
|
+
if (!record || (id === "codex-runtime" && !keepsCodex)) continue;
|
|
187
|
+
if (id === "codex-runtime" && compareVersions(ownership.version, releaseVersion) >= 0) {
|
|
188
|
+
removals.push({ id: "previous-codex-runtime", phase: "cleanup", path: record.path, type: "tree", operation: "remove", ownedBy: id });
|
|
189
|
+
} else {
|
|
190
|
+
candidates.push({ entry: { id, version: ownership.version, path: record.path, installedDigest: record.installedDigest, retiredAt }, retiring: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const entry of ownership.retained) {
|
|
195
|
+
if (entry.id === "codex-runtime" && !keepsCodex) {
|
|
196
|
+
removals.push(retiredRuntimeTarget(entry, "cleanup"));
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (entry.version === releaseVersion) continue;
|
|
200
|
+
const expired = now.getTime() - Date.parse(entry.retiredAt) >= RETAINED_RUNTIME_GRACE_MS;
|
|
201
|
+
const shadowsRelease = entry.id === "codex-runtime" && compareVersions(entry.version, releaseVersion) >= 0;
|
|
202
|
+
if (expired || shadowsRelease) removals.push(retiredRuntimeTarget(entry, "cleanup"));
|
|
203
|
+
else candidates.push({ entry, retiring: false });
|
|
204
|
+
}
|
|
205
|
+
const retained = [];
|
|
206
|
+
for (const id of RETAINED_RUNTIME_IDS) {
|
|
207
|
+
const newestFirst = candidates.filter(({ entry }) => entry.id === id).sort((left, right) => (right.retiring - left.retiring)
|
|
208
|
+
|| (Date.parse(right.entry.retiredAt) - Date.parse(left.entry.retiredAt)) || compareVersions(right.entry.version, left.entry.version));
|
|
209
|
+
for (const [index, { entry, retiring }] of newestFirst.entries()) {
|
|
210
|
+
if (index < RETAINED_RUNTIME_LIMIT) retained.push(entry);
|
|
211
|
+
else if (!retiring) removals.push(retiredRuntimeTarget(entry, "cleanup"));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { removals, retained: retained.sort((left, right) => Date.parse(left.retiredAt) - Date.parse(right.retiredAt)) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function retiredRuntimeTarget(entry, phase) {
|
|
218
|
+
return { id: `retired-${entry.id}-${entry.version}`, phase, path: entry.path, type: "tree", operation: "remove", retained: entry };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Files are committed first; registering with Claude Code is idempotent, and a failure leaves the files
|
|
222
|
+
// installed with a manual command to finish, instead of rolling back a working Codex/Antigravity install.
|
|
223
|
+
// A missing claude CLI is only advice (exit 0), except under `upgrade --auto`. Under `--auto` every Claude Code step
|
|
224
|
+
// left to the user exits AUTO_UPGRADE_EXIT.manual so the runner can tell it from other errors; a foreign `pota`
|
|
225
|
+
// marketplace is not Pota's to change and exits 0 as in a manual upgrade.
|
|
226
|
+
function reportClaudeRegistration({ auto = false } = {}) {
|
|
227
|
+
const register = `claude plugin marketplace add ${JSON.stringify(claudePluginMarketplaceRoot)} && claude plugin install ${claudeRegistration.pluginId} --scope user`;
|
|
228
|
+
const failedExit = auto ? AUTO_UPGRADE_EXIT.manual : 1;
|
|
229
|
+
try {
|
|
230
|
+
const result = claudeRegistration.register();
|
|
231
|
+
if (result.registered) {
|
|
232
|
+
console.log(`Claude Code plugin: ${claudeRegistration.pluginId} (${claudePluginMarketplaceRoot})`);
|
|
233
|
+
if (result.cacheRefresh && !result.cacheRefresh.refreshed) {
|
|
234
|
+
const refresh = `claude plugin marketplace update ${marketplaceName} && claude plugin update ${claudeRegistration.pluginId} --scope user`;
|
|
235
|
+
console.log(`Claude Code plugin cache refresh failed: ${result.cacheRefresh.reason}. To finish: ${refresh}`);
|
|
236
|
+
process.exitCode = failedExit;
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
console.log(`Claude Code plugin not registered: ${result.reason}. To register: ${register}`);
|
|
241
|
+
if (auto && result.cliMissing) process.exitCode = AUTO_UPGRADE_EXIT.manual;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.log(`Claude Code plugin registration failed: ${error.message}. To register: ${register}`);
|
|
244
|
+
process.exitCode = failedExit;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function uninstall() {
|
|
249
|
+
await recoverInterruptedTransaction();
|
|
250
|
+
const recorded = await readOwnership({ required: true });
|
|
251
|
+
validateOwnershipShape(recorded, { required: true });
|
|
252
|
+
// A retained runtime that is already gone is not a conflict and has nothing left to remove.
|
|
253
|
+
const ownership = await withoutMissingRetained(recorded);
|
|
254
|
+
const conflicts = [];
|
|
255
|
+
const retiredRuntimes = (ownership.retained ?? []).map((entry) => retiredRuntimeTarget(entry, "core"));
|
|
256
|
+
for (const record of [...ownership.targets, ...retiredRuntimes.map((target) => ({ ...target, installedDigest: target.retained.installedDigest }))]) {
|
|
257
|
+
const current = await targetDigest(record.path, record.type);
|
|
258
|
+
if (record.type !== "config" && current !== record.installedDigest) conflicts.push(record.path);
|
|
259
|
+
}
|
|
260
|
+
if (conflicts.length > 0) throw new Error(`Refusing to uninstall modified managed artifacts: ${conflicts.join(", ")}`);
|
|
261
|
+
// Unregister before removing files so Claude Code never points at a deleted plugin; a failure aborts with nothing changed.
|
|
262
|
+
// Without Claude Code in the selection the claude CLI is never run.
|
|
263
|
+
const unregistered = hostsOf(ownership).includes("claude") ? claudeRegistration.unregister().unregistered : false;
|
|
264
|
+
const transaction = await createTransaction();
|
|
265
|
+
try {
|
|
266
|
+
const configRecord = ownership.targets.find((record) => record.type === "config");
|
|
267
|
+
if (configRecord && await exists(configRecord.path)) {
|
|
268
|
+
const snapshot = await readSharedConfigSnapshot(configRecord.path);
|
|
269
|
+
const staged = join(transaction.stageRoot, "uninstall-config.toml");
|
|
270
|
+
await mkdir(dirname(staged), { recursive: true });
|
|
271
|
+
await writeFile(staged, removePotaConfig(snapshot.text));
|
|
272
|
+
await replaceTarget({ ...configRecord, staged, configSourceDigest: snapshot.digest }, transaction);
|
|
273
|
+
}
|
|
274
|
+
injectFailure("uninstall-after-config");
|
|
275
|
+
for (const phase of ["antigravity", "claude"]) {
|
|
276
|
+
for (const record of ownership.targets.filter((candidate) => candidate.phase === phase).reverse()) await removeManagedTarget(record, transaction);
|
|
277
|
+
}
|
|
278
|
+
injectFailure("uninstall-after-external");
|
|
279
|
+
for (const phase of ["codex", "core"]) {
|
|
280
|
+
for (const record of ownership.targets.filter((candidate) => candidate.phase === phase).reverse()) await removeManagedTarget(record, transaction);
|
|
281
|
+
}
|
|
282
|
+
for (const target of retiredRuntimes) await removeManagedTarget(target, transaction);
|
|
283
|
+
injectFailure("uninstall-after-codex");
|
|
284
|
+
await removeManagedTarget({ id: "ownership", phase: "manifest", path: ownershipPath, type: "file" }, transaction);
|
|
285
|
+
injectFailure("uninstall-after-ownership");
|
|
286
|
+
await finishTransaction(transaction);
|
|
287
|
+
injectFailure("uninstall-after-commit");
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (transaction.state !== "committed") {
|
|
290
|
+
await rollback(transaction);
|
|
291
|
+
// The rollback restored the plugin files, so restore the Claude Code registration that preceded them.
|
|
292
|
+
if (unregistered) {
|
|
293
|
+
try { claudeRegistration.register(); } catch (registrationError) { console.error(`Claude Code plugin re-registration failed: ${registrationError.message}`); }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
await pruneUninstallScaffolds();
|
|
299
|
+
console.log(t("uninstallComplete"));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Only the selected hosts are checked, and only their host CLIs are run; without a valid manifest every host is checked.
|
|
303
|
+
// Version-dependent paths follow the installed version, so a CLI of another version checks the installation as it is.
|
|
304
|
+
async function doctor() {
|
|
305
|
+
const checks = [];
|
|
306
|
+
const ownership = await readOwnership();
|
|
307
|
+
const hosts = hostsOf(ownership);
|
|
308
|
+
const selected = (host) => hosts.includes(host);
|
|
309
|
+
console.log(`Hosts: ${hosts.join(",")}`);
|
|
310
|
+
const installedVersion = typeof ownership?.version === "string" && VERSION_PATTERN.test(ownership.version) ? ownership.version : releaseVersion;
|
|
311
|
+
if (installedVersion !== releaseVersion) console.log(`CLI version ${releaseVersion} differs from installed version ${installedVersion}`);
|
|
312
|
+
const runtimeRoot = layout.runtimeRootFor(installedVersion);
|
|
313
|
+
const codexRuntimeRoot = layout.codexRuntimeRootFor(installedVersion);
|
|
314
|
+
const installedRuntimeLocatorPath = join(runtimeRoot, ".pota-runtime.json");
|
|
315
|
+
checks.push(["source", await exists(join(pluginRoot, ".codex-plugin", "plugin.json"))]);
|
|
316
|
+
checks.push(["ownership manifest", Boolean(ownership)]);
|
|
317
|
+
checks.push(["ownership digests", await ownershipDigestsValid(ownership)]);
|
|
318
|
+
if (selected("codex")) checks.push(["custom marketplace source", await exists(join(installedPluginRoot, ".codex-plugin", "plugin.json"))]);
|
|
319
|
+
checks.push(["canonical runtime", await exists(join(runtimeRoot, ".codex-plugin", "plugin.json"))]);
|
|
320
|
+
if (selected("codex")) {
|
|
321
|
+
checks.push(["versioned runtime", await exists(join(codexRuntimeRoot, ".codex-plugin", "plugin.json"))]);
|
|
322
|
+
checks.push(["marketplace", await exists(marketplaceJsonPath)]);
|
|
323
|
+
checks.push(["installed hooks manifest field", (await readJson(join(codexRuntimeRoot, ".codex-plugin", "plugin.json")))?.hooks === "./hooks/hooks.json"]);
|
|
324
|
+
checks.push(["hooks file", await exists(join(codexRuntimeRoot, "hooks", "hooks.json"))]);
|
|
325
|
+
}
|
|
326
|
+
checks.push(["installed runtime locator", await validRuntimeLocator(installedRuntimeLocatorPath, runtimeRoot)]);
|
|
327
|
+
checks.push(["installed skill locators", (await Promise.all(skillNames().map((name) => validRuntimeLocator(installedSkillLocatorPath(runtimeRoot, name), runtimeRoot)))).every(Boolean)]);
|
|
328
|
+
checks.push(["installed CLI script", await exists(join(runtimeRoot, "scripts", "pota.mjs"))]);
|
|
329
|
+
checks.push(["installed plan template", await exists(join(runtimeRoot, "templates", "work-plan.md"))]);
|
|
330
|
+
checks.push(["installed model guidance", await exists(join(runtimeRoot, "docs", "model-guidance", "codex.md"))]);
|
|
331
|
+
if (selected("codex")) {
|
|
332
|
+
const config = (await readSharedConfigSnapshot(configPath)).text;
|
|
333
|
+
checks.push(["config marketplace", splitSections(config).some((section) => section.header === "marketplaces.pota")]);
|
|
334
|
+
checks.push(["config plugin enabled", hasSetting(config, 'plugins."pota@pota"', "enabled", "true")]);
|
|
335
|
+
}
|
|
336
|
+
if (selected("claude")) {
|
|
337
|
+
checks.push(["Claude runtime locator", await validRuntimeLocator(claudeRuntimeLocatorPath, runtimeRoot)]);
|
|
338
|
+
const claudePluginManifest = await readJson(join(claudePluginMarketplaceRoot, pluginName, ".claude-plugin", "plugin.json"));
|
|
339
|
+
checks.push(["Claude plugin files", claudePluginManifest?.version === installedVersion
|
|
340
|
+
&& await exists(join(claudePluginMarketplaceRoot, ".claude-plugin", "marketplace.json"))
|
|
341
|
+
&& await exists(join(claudePluginMarketplaceRoot, pluginName, "hooks", "hooks.json"))]);
|
|
342
|
+
let claudeStatus = null;
|
|
343
|
+
try { claudeStatus = claudeRegistration.status(); } catch { claudeStatus = null; }
|
|
344
|
+
checks.push(["Claude plugin registered", Boolean(claudeStatus?.marketplace && claudeStatus.installed)]);
|
|
345
|
+
checks.push(["Claude plugin enabled", Boolean(claudeStatus?.enabled)]);
|
|
346
|
+
checks.push(["Claude plugin cache version", claudeStatus?.version === installedVersion]);
|
|
347
|
+
}
|
|
348
|
+
if (selected("antigravity")) {
|
|
349
|
+
checks.push(["Antigravity IDE skills", await exists(join(antigravityHome, "skills", "pota-plan", "SKILL.md"))]);
|
|
350
|
+
checks.push(["Antigravity CLI flat skills", await exists(join(antigravityCliHome, "skills", "pota-plan.md"))]);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (selected("codex")) {
|
|
354
|
+
const codexStatus = hostJson(["plugin", "list", "--json"], { CODEX_HOME: codexHome });
|
|
355
|
+
const codexEntry = findPluginStatus(codexStatus, "pota@pota");
|
|
356
|
+
checks.push(["Codex plugin installed", Boolean(codexEntry?.installed)]);
|
|
357
|
+
checks.push(["Codex plugin enabled", Boolean(codexEntry?.enabled)]);
|
|
358
|
+
checks.push(["Codex plugin version", codexEntry?.version === installedVersion]);
|
|
359
|
+
|
|
360
|
+
const featureResult = spawnSync("codex", ["features", "list"], { encoding: "utf8", env: { ...process.env, CODEX_HOME: codexHome } });
|
|
361
|
+
const featureStates = featureResult.status === 0 && !featureResult.error ? parseCodexFeatureList(featureResult.stdout) : null;
|
|
362
|
+
checks.push(["Codex feature plugins", featureStates?.plugins === true]);
|
|
363
|
+
checks.push(["Codex feature hooks", featureStates?.hooks === true]);
|
|
364
|
+
checks.push(["Codex feature multi_agent", featureStates?.multi_agent === true]);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (selected("antigravity")) {
|
|
368
|
+
const agy = spawnSync("agy", ["--version"], { encoding: "utf8", env: process.env });
|
|
369
|
+
checks.push(["Antigravity CLI readiness", !agy.error && agy.status === 0 && /^\d+\.\d+\.\d+/.test(agy.stdout.trim())]);
|
|
370
|
+
checks.push(["Antigravity CLI skill validation", await validateAntigravitySkills(antigravityCliHome, skillNames(), runtimeRoot)]);
|
|
371
|
+
}
|
|
372
|
+
for (const [name, ok] of checks) console.log(`${ok ? "OK" : "FAIL"} ${name}`);
|
|
373
|
+
if (checks.some(([, ok]) => !ok)) process.exitCode = 1;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
function injectFailure(point) {
|
|
378
|
+
if (process.env.POTA_TEST_FAIL_PHASE === point) throw new Error(`Injected lifecycle failure: ${point}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The installed copies live under the install root or the Codex cache; comparing real paths keeps a symlinked
|
|
382
|
+
// temp or home directory from hiding that the CLI was started from one of them.
|
|
383
|
+
function isInsidePath(root, candidate) {
|
|
384
|
+
const real = (path) => { try { return realpathSync(path); } catch { return resolve(path); } };
|
|
385
|
+
const [base, path] = [real(root), real(candidate)];
|
|
386
|
+
return path === base || path.startsWith(`${base}${sep}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function hostJson(args, extraEnv = {}) {
|
|
390
|
+
const result = spawnSync("codex", args, { encoding: "utf8", env: { ...process.env, ...extraEnv } });
|
|
391
|
+
if (result.error || result.status !== 0) return null;
|
|
392
|
+
try { return JSON.parse(result.stdout); } catch { return null; }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function findPluginStatus(value, id) {
|
|
396
|
+
const entries = Array.isArray(value) ? value : value?.installed ?? value?.plugins ?? value?.data ?? [];
|
|
397
|
+
return entries.find((entry) => entry.pluginId === id || entry.id === id || entry.name === id || `${entry.name}@${entry.marketplaceName ?? entry.marketplace}` === id) ?? null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function parseCodexFeatureList(output) {
|
|
401
|
+
if (typeof output !== "string" || output.trim().length === 0) return null;
|
|
402
|
+
const features = {};
|
|
403
|
+
for (const line of output.split(/\r?\n/)) {
|
|
404
|
+
const match = line.trim().match(/^([A-Za-z0-9_]+)\s+.+?\s+(true|false)$/);
|
|
405
|
+
if (match) features[match[1]] = match[2] === "true";
|
|
406
|
+
}
|
|
407
|
+
return Object.keys(features).length > 0 ? features : null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function readJson(path) { if (!(await exists(path))) return null; return JSON.parse(await readFile(path, "utf8")); }
|
|
411
|
+
|
|
412
|
+
return { doctor, install, uninstall };
|
|
413
|
+
|
|
414
|
+
async function exists(path) {
|
|
415
|
+
try { await lstat(path); return true; }
|
|
416
|
+
catch (error) { if (error?.code === "ENOENT") return false; throw error; }
|
|
417
|
+
}
|
|
418
|
+
}
|