@uniqbit/mate-core 0.17.0-canary.9 → 0.17.1
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/package.json +11 -5
- package/src/cli/commands/artifact/finish/index.ts +0 -9
- package/src/cli/commands/artifact/finish/openspec.ts +2 -2
- package/src/cli/commands/artifact/finish/registry.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +1 -1
- package/src/cli/commands/artifact/pending/index.ts +0 -16
- package/src/cli/commands/cap/graphify.ts +1 -1
- package/src/cli/commands/cap/index-cmd.ts +1 -1
- package/src/cli/commands/cap/tokensave.ts +4 -3
- package/src/cli/commands/companion/companion.ts +4 -0
- package/src/cli/commands/companion/hub.ts +2 -1
- package/src/cli/commands/companion/list.ts +2 -1
- package/src/cli/commands/companion/prepare.ts +2 -2
- package/src/cli/commands/companion/update.ts +334 -0
- package/src/cli/commands/doctor.ts +2 -1
- package/src/cli/commands/install.ts +59 -5
- package/src/cli/commands/launch/shared.ts +2 -1
- package/src/cli/commands/plugin/plugin.ts +4 -0
- package/src/cli/commands/plugin/verify.ts +25 -0
- package/src/cli/commands/report/highlight.ts +1 -4
- package/src/cli/commands/report/renderer.ts +0 -2
- package/src/cli/commands/studio/access.ts +201 -0
- package/src/cli/commands/studio/index.ts +207 -26
- package/src/cli/commands/studio/openspec-cli.ts +1 -1
- package/src/cli/commands/studio/selection.ts +127 -11
- package/src/cli/commands/studio/server.ts +355 -64
- package/src/cli/commands/studio/terminal-assets.ts +50 -0
- package/src/cli/commands/studio/terminal-launch.ts +71 -0
- package/src/cli/commands/studio/terminal.ts +593 -0
- package/src/cli/commands/studio/vault.ts +320 -75
- package/src/cli/commands/studio/views/client.ts +90 -9
- package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
- package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
- package/src/cli/commands/studio/views/document.tsx +411 -107
- package/src/cli/commands/studio/views/model.ts +14 -1
- package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
- package/src/cli/commands/studio/views/styles.ts +106 -13
- package/src/cli/commands/studio/views/terminal.tsx +426 -0
- package/src/cli/commands/studio/views/vault/state.ts +42 -0
- package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
- package/src/cli/commands/update.ts +11 -8
- package/src/cli/companion-link-wizard.tsx +1 -1
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/launch-selector.tsx +1 -1
- package/src/cli/main.ts +2 -1
- package/src/cli/usage.ts +7 -3
- package/src/cli/write-json-stdout.ts +2 -2
- package/src/lib/components/spinner.tsx +2 -2
- package/src/lib/components/startup-progress.tsx +1 -1
- package/src/lib/context-mode-package.ts +1 -1
- package/src/lib/opencode-plugin-package.ts +3 -0
- package/src/lib/orchestrator/adapters/opencode.ts +53 -13
- package/src/lib/orchestrator/companion-hub.ts +3 -3
- package/src/lib/orchestrator/companion-registry-store.ts +1 -1
- package/src/lib/orchestrator/config-store.ts +2 -2
- package/src/lib/orchestrator/editor.ts +4 -12
- package/src/lib/orchestrator/engine-guard.ts +9 -1
- package/src/lib/orchestrator/projection-entries.ts +1 -1
- package/src/lib/orchestrator/projection-record.ts +1 -1
- package/src/lib/orchestrator/projection-types.ts +0 -4
- package/src/lib/orchestrator/repo-local-registry.ts +0 -1
- package/src/lib/orchestrator/root-context.ts +1 -1
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +1 -1
- package/src/lib/orchestrator/types.ts +1 -1
- package/src/lib/orchestrator/working-repo-projection.ts +1 -1
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/package-paths.ts +1 -11
- package/src/lib/package-root.ts +43 -0
- package/src/lib/prebuilt-workspace.ts +1 -1
- package/src/lib/public-npm.ts +1 -1
- package/src/opencode/companion-hooks.ts +131 -153
- package/src/opencode/index.ts +12 -6
- package/src/opencode/tui.tsx +46 -49
- package/src/playbooks/companion-guidance.ts +1 -28
- package/src/runtime/companion-sync.ts +0 -3
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/providers/opencode/opencode.json +6 -4
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
- package/src/tools/setup/capabilities/context-mode.ts +45 -25
- package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
- package/src/tools/setup/capabilities/graphify.ts +2 -2
- package/src/tools/setup/capabilities/openspec.ts +2 -7
- package/src/tools/setup/capabilities/react-doctor.ts +1 -1
- package/src/tools/setup/capabilities/rtk.ts +2 -2
- package/src/tools/setup/capabilities/tokensave.ts +98 -12
- package/src/tools/setup/context-services.ts +3 -6
- package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
- package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
- package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
- package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
- package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
- package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
- package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/providers/claude-format.ts +1 -1
- package/src/tools/setup/providers/claude.ts +1 -1
- package/src/tools/setup/providers/opencode-format.ts +220 -25
- package/src/tools/setup/providers/opencode.ts +63 -21
- package/src/tools/setup/runtime-documents.ts +9 -11
- package/src/tools/setup.ts +0 -4
- package/src/cli/commands/studio/views/pairings.tsx +0 -38
- package/src/templates/providers/opencode/tui.json +0 -3
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
RuntimeContributionsByRuntime,
|
|
19
19
|
SetupContext,
|
|
20
20
|
} from "../plugin";
|
|
21
|
+
import type { InstallRequirement } from "../install-contract";
|
|
21
22
|
import { readClaudeMcpConfig } from "../providers/claude-format";
|
|
22
23
|
import { getOpenCodePluginReferences, readOpenCodeConfig } from "../providers/opencode-format";
|
|
23
24
|
import { pruneEmptyAncestors } from "../utils";
|
|
@@ -84,32 +85,27 @@ async function assertNoMcpConflict(ctx: SetupContext, provider: "claude" | "open
|
|
|
84
85
|
*/
|
|
85
86
|
async function validateOpenCodeReferences(ctx: LaunchPreflightContext): Promise<string[]> {
|
|
86
87
|
const expected = getContextModePackageReference();
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const references = getOpenCodePluginReferences(config);
|
|
92
|
-
if (references.includes(expected)) continue;
|
|
88
|
+
const configPath = path.join(ctx.companionPath, ".opencode", "opencode.json");
|
|
89
|
+
const { config } = await readOpenCodeConfig(configPath);
|
|
90
|
+
const references = getOpenCodePluginReferences(config);
|
|
91
|
+
if (references.includes(expected)) return [];
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
diagnostics.push(
|
|
109
|
-
`Missing or stale context-mode package reference in ${configPath}; expected ${expected}.`,
|
|
110
|
-
);
|
|
93
|
+
const bound = references.find(
|
|
94
|
+
(reference): reference is string =>
|
|
95
|
+
typeof reference === "string" &&
|
|
96
|
+
isPreinstalledPluginPath(reference, CONTEXT_MODE_PACKAGE_NAME),
|
|
97
|
+
);
|
|
98
|
+
if (bound !== undefined) {
|
|
99
|
+
const version = await installedVersionAt(bound);
|
|
100
|
+
if (version === CONTEXT_MODE_VERSION) return [];
|
|
101
|
+
return [
|
|
102
|
+
`Stale context-mode package reference in ${configPath}; it points at ${bound}, which is ${version ?? "not installed"} rather than ${CONTEXT_MODE_VERSION}.`,
|
|
103
|
+
];
|
|
111
104
|
}
|
|
112
|
-
|
|
105
|
+
|
|
106
|
+
return [
|
|
107
|
+
`Missing or stale context-mode package reference in ${configPath}; expected ${expected}.`,
|
|
108
|
+
];
|
|
113
109
|
}
|
|
114
110
|
|
|
115
111
|
export function createContextModePlugin(deps: ContextModePluginDeps = {}): CapabilityPlugin {
|
|
@@ -144,7 +140,7 @@ export function createContextModePlugin(deps: ContextModePluginDeps = {}): Capab
|
|
|
144
140
|
{
|
|
145
141
|
reference: getContextModePackageReference(),
|
|
146
142
|
isManagedReference: isContextModePackageReference,
|
|
147
|
-
configFiles: ["opencode.json"
|
|
143
|
+
configFiles: ["opencode.json"],
|
|
148
144
|
preinstalled: {
|
|
149
145
|
packageName: CONTEXT_MODE_PACKAGE_NAME,
|
|
150
146
|
version: CONTEXT_MODE_VERSION,
|
|
@@ -154,6 +150,30 @@ export function createContextModePlugin(deps: ContextModePluginDeps = {}): Capab
|
|
|
154
150
|
},
|
|
155
151
|
};
|
|
156
152
|
},
|
|
153
|
+
/**
|
|
154
|
+
* The machine-local workspace is gitignored, so a fresh checkout has none;
|
|
155
|
+
* `mate install` provisions it here because sync-mode apply only validates.
|
|
156
|
+
*/
|
|
157
|
+
getInstallRequirements: ({ companionPath }): InstallRequirement[] => {
|
|
158
|
+
if (!companionPath) return [];
|
|
159
|
+
const command = `npm install ${getContextModePackageReference()} (in .mate/plugins/.local)`;
|
|
160
|
+
return [
|
|
161
|
+
{
|
|
162
|
+
id: "capability:context-mode",
|
|
163
|
+
label: "Context Mode package",
|
|
164
|
+
group: "companion",
|
|
165
|
+
source: "Context Mode capability",
|
|
166
|
+
command,
|
|
167
|
+
fingerprint: `context-mode:${command}`,
|
|
168
|
+
detect: () =>
|
|
169
|
+
validatePackage(companionPath).then(
|
|
170
|
+
() => true,
|
|
171
|
+
() => false,
|
|
172
|
+
),
|
|
173
|
+
install: () => installPackage(companionPath),
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
},
|
|
157
177
|
async apply(ctx) {
|
|
158
178
|
const legacyInstallDir = getLegacyInstallDir(ctx.companionPath);
|
|
159
179
|
await fs.rm(legacyInstallDir, { recursive: true, force: true });
|
|
@@ -9,10 +9,10 @@ import {
|
|
|
9
9
|
// guidance plugin can consume it without an import cycle.
|
|
10
10
|
|
|
11
11
|
// Marker names derive from the framework identity, never the invocation name.
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
const GRAPHIFY_START = () => `<!-- ${FRAMEWORK_NAME.toUpperCase()}:GRAPHIFY:START -->`;
|
|
13
|
+
const GRAPHIFY_END = () => `<!-- ${FRAMEWORK_NAME.toUpperCase()}:GRAPHIFY:END -->`;
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
function isGraphifyHeading(line: string): boolean {
|
|
16
16
|
return /^#{1,6}\s+graphify\s*$/i.test(line);
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
export const GRAPHIFY_STORE_SEGMENT = ".graphify";
|
|
34
34
|
|
|
35
35
|
/** Skill tree the external `graphify install` CLI writes into a runtime directory. */
|
|
36
|
-
|
|
36
|
+
const GRAPHIFY_SKILL_NAME = "graphify";
|
|
37
37
|
export const GRAPHIFY_OUTPUT_SUBDIR = "graphify-out";
|
|
38
38
|
|
|
39
39
|
const GRAPHIFY_INSTALL_CMD = `uv tool install graphifyy`;
|
|
@@ -62,7 +62,7 @@ const GRAPHIFY_PROVIDER_DIRS: Record<string, string> = {
|
|
|
62
62
|
export const GRAPHIFY_COMPANION_OUT_PREFIX =
|
|
63
63
|
"$MATE_ARTIFACT_PATH/.graphify/$MATE_REPO_ID/graphify-out/";
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
const GRAPHIFY_SUPPORTED_PROVIDERS = Object.keys(GRAPHIFY_PROVIDER_DIRS);
|
|
66
66
|
|
|
67
67
|
const CLAUDE_MANAGED_HOOK_COMMAND_SUFFIXES = new Set([
|
|
68
68
|
"/.claude/hooks/validate-artifact-path",
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "../utils";
|
|
22
22
|
import { applyMateSkills, teardownMateSkills } from "../mate";
|
|
23
23
|
|
|
24
|
-
export { MATE_ARTIFACT_SKILLS, MATE_SKILLS
|
|
24
|
+
export { MATE_ARTIFACT_SKILLS, MATE_SKILLS } from "../mate";
|
|
25
25
|
|
|
26
26
|
const execFileAsync = promisify(execFileCb);
|
|
27
27
|
|
|
@@ -108,10 +108,7 @@ function getMateConventionsPath(companionPath: string): string {
|
|
|
108
108
|
return path.join(companionPath, "openspec", "mate-conventions.yaml");
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
skillsDir: string,
|
|
113
|
-
companionPath: string,
|
|
114
|
-
): Promise<void> {
|
|
111
|
+
async function teardownOpenspecSkills(skillsDir: string, companionPath: string): Promise<void> {
|
|
115
112
|
for (const skill of OPENSPEC_SKILLS) {
|
|
116
113
|
try {
|
|
117
114
|
await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
|
|
@@ -422,5 +419,3 @@ export function createOpenspecPlugin(deps: OpenSpecPluginDeps = {}): CapabilityP
|
|
|
422
419
|
},
|
|
423
420
|
};
|
|
424
421
|
}
|
|
425
|
-
|
|
426
|
-
export const openspecPlugin = createOpenspecPlugin();
|
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import type { CapabilityPlugin, RuntimeContributionsByRuntime, SetupContext } from "../plugin";
|
|
5
5
|
import { pruneEmptyAncestors } from "../utils";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const SKILL_SRC = path.join(
|
|
8
8
|
import.meta.dirname,
|
|
9
9
|
"../../../templates/capabilities/react-doctor/skill",
|
|
10
10
|
);
|
|
@@ -3,8 +3,8 @@ import type { InstallRequirement } from "../install-contract";
|
|
|
3
3
|
import { isCommandOnPath, runShellCommand, runShellCommandSilently } from "../utils";
|
|
4
4
|
import { confirm } from "../../../cli/confirm";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const RTK_INSTALL_CMD_BREW = "brew install rtk-ai/tap/rtk";
|
|
7
|
+
const RTK_INSTALL_CMD_FALLBACK =
|
|
8
8
|
"curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh";
|
|
9
9
|
|
|
10
10
|
const RTK_INIT_COMMANDS: Record<string, string> = {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { constants as fsConstants } from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
|
|
6
7
|
import { resolveGitInfoExcludePath } from "../git-utils";
|
|
8
|
+
import type { InstallRequirement } from "../install-contract";
|
|
7
9
|
import type { CapabilityPlugin, RuntimeContributionsByRuntime } from "../plugin";
|
|
8
10
|
import { isCommandOnPath, resolveCommandOnPath, runCommand, runShellCommand } from "../utils";
|
|
9
11
|
import { TOKENSAVE_CLAUDE_MD_MARKER } from "./tokensave-shared";
|
|
@@ -73,6 +75,9 @@ export const tokensaveDeps = {
|
|
|
73
75
|
pathValue(): string {
|
|
74
76
|
return process.env.PATH ?? "";
|
|
75
77
|
},
|
|
78
|
+
homeDir(): string {
|
|
79
|
+
return os.homedir();
|
|
80
|
+
},
|
|
76
81
|
};
|
|
77
82
|
|
|
78
83
|
async function cleanupRepoLocalTokensaveArtifacts(repoPath: string): Promise<void> {
|
|
@@ -109,11 +114,12 @@ async function tokensaveInstalled(repoPath: string): Promise<boolean> {
|
|
|
109
114
|
|
|
110
115
|
/**
|
|
111
116
|
* Upgrades the installed binary without making setup depend on network
|
|
112
|
-
* availability. `--kill
|
|
113
|
-
*
|
|
117
|
+
* availability. Never passes `--kill`: it stops every tokensave process on the
|
|
118
|
+
* machine, including MCP servers of running agent sessions. Without a TTY,
|
|
119
|
+
* tokensave upgrades and leaves them running.
|
|
114
120
|
*/
|
|
115
121
|
async function upgradeTokensave(repoPath: string): Promise<void> {
|
|
116
|
-
const result = tokensaveDeps.run(["upgrade"
|
|
122
|
+
const result = tokensaveDeps.run(["upgrade"], repoPath);
|
|
117
123
|
if (result.ok) return;
|
|
118
124
|
|
|
119
125
|
const detail = result.stderr.trim();
|
|
@@ -122,17 +128,22 @@ async function upgradeTokensave(repoPath: string): Promise<void> {
|
|
|
122
128
|
);
|
|
123
129
|
}
|
|
124
130
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Global agent integration (MCP entry, session hooks, wildcard permission grant, and
|
|
133
|
+
* tokensave's own config bookkeeping) is owned by tokensave's installer — Mate never
|
|
134
|
+
* hand-edits ~/.tokensave/config.toml. Idempotent, so re-running doubles as repair.
|
|
135
|
+
*/
|
|
136
|
+
function installTokensaveAgentIntegration(agent: string, cwd: string): TokensaveRunResult {
|
|
137
|
+
return tokensaveDeps.run(
|
|
138
|
+
["install", "--agent", agent, "--git-hook", "no", "--wildcard-permissions"],
|
|
139
|
+
cwd,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
function installTokensaveAgentIntegrations(providers: string[], cwd: string): void {
|
|
130
144
|
const agents = providers.filter((p) => TOKENSAVE_SUPPORTED_AGENTS.has(p)).sort();
|
|
131
145
|
for (const agent of agents) {
|
|
132
|
-
const result =
|
|
133
|
-
["install", "--agent", agent, "--git-hook", "no", "--wildcard-permissions"],
|
|
134
|
-
cwd,
|
|
135
|
-
);
|
|
146
|
+
const result = installTokensaveAgentIntegration(agent, cwd);
|
|
136
147
|
if (!result.ok) {
|
|
137
148
|
const detail = result.stderr.trim();
|
|
138
149
|
process.stderr.write(
|
|
@@ -142,6 +153,60 @@ function installTokensaveAgentIntegrations(providers: string[], cwd: string): vo
|
|
|
142
153
|
}
|
|
143
154
|
}
|
|
144
155
|
|
|
156
|
+
async function readJsonObject(filePath: string): Promise<Record<string, unknown> | undefined> {
|
|
157
|
+
try {
|
|
158
|
+
const parsed: unknown = JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
159
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
160
|
+
? (parsed as Record<string, unknown>)
|
|
161
|
+
: undefined;
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
168
|
+
try {
|
|
169
|
+
await fs.access(filePath, fsConstants.F_OK);
|
|
170
|
+
return true;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read-only probe of the global files `tokensave install --agent <agent>` writes.
|
|
178
|
+
* tokensave's own permission check reads only these global files, never the
|
|
179
|
+
* companion-scoped settings Mate loads via `--settings`.
|
|
180
|
+
*/
|
|
181
|
+
export async function isTokensaveAgentIntegrated(agent: string): Promise<boolean> {
|
|
182
|
+
const home = tokensaveDeps.homeDir();
|
|
183
|
+
if (agent === "claude") {
|
|
184
|
+
const settings = await readJsonObject(path.join(home, ".claude", "settings.json"));
|
|
185
|
+
const allow = (settings?.permissions as { allow?: unknown } | undefined)?.allow;
|
|
186
|
+
return (
|
|
187
|
+
Array.isArray(allow) &&
|
|
188
|
+
allow.includes("mcp__tokensave__*") &&
|
|
189
|
+
(await fileExists(path.join(home, ".claude", "rules", "tokensave.md")))
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (agent === "opencode") {
|
|
193
|
+
const configDir = path.join(home, ".config", "opencode");
|
|
194
|
+
const config = await readJsonObject(path.join(configDir, "opencode.json"));
|
|
195
|
+
/**
|
|
196
|
+
* `tokensave install` writes the flat `mcp.<name>` entry, which OpenCode 2.x
|
|
197
|
+
* still loads; `mcp.servers.<name>` is the 2.x-native spelling.
|
|
198
|
+
*/
|
|
199
|
+
const mcp = config?.mcp as
|
|
200
|
+
| (Record<string, unknown> & { servers?: Record<string, unknown> })
|
|
201
|
+
| undefined;
|
|
202
|
+
return (
|
|
203
|
+
(mcp?.tokensave !== undefined || mcp?.servers?.tokensave !== undefined) &&
|
|
204
|
+
(await fileExists(path.join(configDir, "tokensave.md")))
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
145
210
|
function getCargoRustVersionError(): string | undefined {
|
|
146
211
|
const result = tokensaveDeps.rustcVersion();
|
|
147
212
|
const version = `${result.stdout}\n${result.stderr}`.match(/\brustc\s+(\d+)\.(\d+)\.(\d+)/);
|
|
@@ -361,7 +426,7 @@ export function createTokensavePlugin(): CapabilityPlugin {
|
|
|
361
426
|
"Enable TokenSave indexing in the repo and MCP access via companion-managed config.",
|
|
362
427
|
defaultSelected: false,
|
|
363
428
|
isEnabled: (config) => (config.capabilities ?? []).some((c) => c.name === "tokensave"),
|
|
364
|
-
getInstallRequirements: () => {
|
|
429
|
+
getInstallRequirements: ({ config }) => {
|
|
365
430
|
const plan = getTokensaveInstallPlan();
|
|
366
431
|
return [
|
|
367
432
|
{
|
|
@@ -382,6 +447,27 @@ export function createTokensavePlugin(): CapabilityPlugin {
|
|
|
382
447
|
},
|
|
383
448
|
verify: () => tokensaveDeps.run(["--version"], process.cwd()).ok,
|
|
384
449
|
},
|
|
450
|
+
...(config.allowedAgents ?? [])
|
|
451
|
+
.filter((agent) => TOKENSAVE_SUPPORTED_AGENTS.has(agent))
|
|
452
|
+
.sort()
|
|
453
|
+
.map((agent): InstallRequirement => ({
|
|
454
|
+
id: `capability:tokensave:${agent}`,
|
|
455
|
+
label: `TokenSave ${agent} integration`,
|
|
456
|
+
group: "companion",
|
|
457
|
+
source: "TokenSave capability",
|
|
458
|
+
command: `tokensave install --agent ${agent} --git-hook no --wildcard-permissions`,
|
|
459
|
+
fingerprint: `tokensave-agent:${agent}`,
|
|
460
|
+
detect: () => isTokensaveAgentIntegrated(agent),
|
|
461
|
+
install: async () => {
|
|
462
|
+
const result = installTokensaveAgentIntegration(agent, process.cwd());
|
|
463
|
+
if (!result.ok) {
|
|
464
|
+
const detail = result.stderr.trim();
|
|
465
|
+
throw new Error(
|
|
466
|
+
`\`tokensave install --agent ${agent}\` failed${detail ? `: ${detail}` : ""}`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
})),
|
|
385
471
|
];
|
|
386
472
|
},
|
|
387
473
|
// MCP access, session hooks, and permission pre-seeds are declared below
|
|
@@ -31,10 +31,7 @@ interface ContextServicesManifest {
|
|
|
31
31
|
plugins: Record<string, PluginServiceRecord>;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
companionPath: string,
|
|
36
|
-
frameworkName: string,
|
|
37
|
-
): string {
|
|
34
|
+
function getContextServicesManifestPath(companionPath: string, frameworkName: string): string {
|
|
38
35
|
return path.join(companionPath, `.${frameworkName}`, "state", "context-services.json");
|
|
39
36
|
}
|
|
40
37
|
|
|
@@ -129,7 +126,7 @@ export async function removeManagedBlocksForPlugin(
|
|
|
129
126
|
}
|
|
130
127
|
|
|
131
128
|
/** Remove a marker-delimited block if present. No-op when file or block is absent. */
|
|
132
|
-
|
|
129
|
+
async function removeManagedBlock(
|
|
133
130
|
filePath: string,
|
|
134
131
|
frameworkName: string,
|
|
135
132
|
blockKey: string,
|
|
@@ -164,7 +161,7 @@ export async function removeManagedBlock(
|
|
|
164
161
|
* `playbooks/**`, and `wrappers/bin/*` below the root. Core's own templates
|
|
165
162
|
* live at `src/templates`.
|
|
166
163
|
*/
|
|
167
|
-
|
|
164
|
+
async function resolveTemplateAsset(
|
|
168
165
|
templatePath: string,
|
|
169
166
|
overrideRoots: string[] = [],
|
|
170
167
|
): Promise<string> {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
|
|
6
|
+
import { FRAMEWORK_NAME } from "../../../framework";
|
|
7
|
+
import { PLUGIN_DECLARATION_POLICIES } from "../../../lib/orchestrator/config-store";
|
|
8
|
+
import type { PluginDeclaration } from "../../../lib/orchestrator/types";
|
|
9
|
+
|
|
10
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Raw read of the companion's `plugins:` list. Deliberately avoids
|
|
16
|
+
* `ConfigStore.load()` — hydration runs on every invocation and must never
|
|
17
|
+
* create or migrate config files as a side effect.
|
|
18
|
+
*/
|
|
19
|
+
export async function readDeclarations(companionPath: string): Promise<unknown[]> {
|
|
20
|
+
try {
|
|
21
|
+
const raw = await fs.readFile(
|
|
22
|
+
path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
|
|
23
|
+
"utf8",
|
|
24
|
+
);
|
|
25
|
+
const parsed = parse(raw) as unknown;
|
|
26
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) return [];
|
|
27
|
+
return parsed.plugins;
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateDeclaration(entry: unknown): {
|
|
34
|
+
declaration?: PluginDeclaration;
|
|
35
|
+
error?: string;
|
|
36
|
+
} {
|
|
37
|
+
if (!isRecord(entry) || typeof entry.package !== "string" || typeof entry.version !== "string") {
|
|
38
|
+
return { error: `ignoring malformed plugins entry: ${JSON.stringify(entry)}` };
|
|
39
|
+
}
|
|
40
|
+
const policy = entry.policy;
|
|
41
|
+
if (policy !== undefined && !PLUGIN_DECLARATION_POLICIES.includes(policy as never)) {
|
|
42
|
+
return {
|
|
43
|
+
error: `plugin "${entry.package}": policy "${String(policy)}" is not allowed for declared plugins (allowed: ${PLUGIN_DECLARATION_POLICIES.join(", ")}).`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return { declaration: entry as unknown as PluginDeclaration };
|
|
47
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { PluginDeclaration } from "../../../lib/orchestrator/types";
|
|
6
|
+
import type { NpmInstallRunner, PluginInstallResult } from "./install";
|
|
7
|
+
import { dynamicPluginsWorkspaceRoot } from "./paths";
|
|
8
|
+
import {
|
|
9
|
+
disallowedPluginMessage,
|
|
10
|
+
isPluginAllowed,
|
|
11
|
+
PluginPolicyError,
|
|
12
|
+
readPluginPolicy,
|
|
13
|
+
} from "./policy";
|
|
14
|
+
|
|
15
|
+
/** Raised before anything is installed: policy, manifest or lockfile inputs are unusable. */
|
|
16
|
+
export class FrozenInstallError extends Error {}
|
|
17
|
+
|
|
18
|
+
export interface FrozenInstallDeps {
|
|
19
|
+
/** Restores the exact locked tree; defaults to `npm ci`. */
|
|
20
|
+
runNpmCi?: NpmInstallRunner;
|
|
21
|
+
env?: Record<string, string | undefined>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface LockEntry {
|
|
25
|
+
version?: string;
|
|
26
|
+
integrity?: string;
|
|
27
|
+
optional?: boolean;
|
|
28
|
+
inBundle?: boolean;
|
|
29
|
+
link?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Lockfile {
|
|
33
|
+
packages?: Record<string, LockEntry & { dependencies?: Record<string, string> }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultNpmCi(workspaceRoot: string): { ok: boolean; detail?: string } {
|
|
37
|
+
const result = spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], {
|
|
38
|
+
cwd: workspaceRoot,
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
});
|
|
41
|
+
if (result.error || result.status !== 0) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
detail:
|
|
45
|
+
result.error?.message ?? (result.stderr?.trim() || `npm ci exited with ${result.status}`),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { ok: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readText(file: string): Promise<string | null> {
|
|
52
|
+
try {
|
|
53
|
+
return await fs.readFile(file, "utf8");
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseJson<T>(text: string, label: string): T {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(text) as T;
|
|
62
|
+
} catch {
|
|
63
|
+
throw new FrozenInstallError(`${label} is not valid JSON`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sameMap(a: Record<string, string>, b: Record<string, string>): string[] {
|
|
68
|
+
const differences: string[] = [];
|
|
69
|
+
for (const name of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
70
|
+
if (a[name] !== b[name]) {
|
|
71
|
+
differences.push(
|
|
72
|
+
`${name} (declared ${a[name] ?? "absent"}, recorded ${b[name] ?? "absent"})`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return differences.toSorted();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compares the installed tree's hidden lockfile with the committed lockfile:
|
|
81
|
+
* every non-optional locked package must be installed at the locked version
|
|
82
|
+
* and integrity, and nothing extra may be installed. Package presence alone
|
|
83
|
+
* proves nothing.
|
|
84
|
+
*/
|
|
85
|
+
async function treeMismatches(workspaceRoot: string, lock: Lockfile): Promise<string[]> {
|
|
86
|
+
const hiddenText = await readText(path.join(workspaceRoot, "node_modules", ".package-lock.json"));
|
|
87
|
+
if (hiddenText === null) return ["installed tree has no install record"];
|
|
88
|
+
let hidden: Lockfile;
|
|
89
|
+
try {
|
|
90
|
+
hidden = JSON.parse(hiddenText) as Lockfile;
|
|
91
|
+
} catch {
|
|
92
|
+
return ["installed tree's install record is unreadable"];
|
|
93
|
+
}
|
|
94
|
+
const installed = hidden.packages ?? {};
|
|
95
|
+
const mismatches: string[] = [];
|
|
96
|
+
for (const [key, locked] of Object.entries(lock.packages ?? {})) {
|
|
97
|
+
if (key === "" || locked.link) continue;
|
|
98
|
+
const present = installed[key];
|
|
99
|
+
if (!present) {
|
|
100
|
+
if (!locked.optional) mismatches.push(`${key} is not installed`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (present.version !== locked.version || present.integrity !== locked.integrity) {
|
|
104
|
+
mismatches.push(`${key} differs from the lockfile`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const key of Object.keys(installed)) {
|
|
108
|
+
if (!lock.packages?.[key]) mismatches.push(`${key} is installed but not locked`);
|
|
109
|
+
}
|
|
110
|
+
return mismatches;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Deployment restore: installs exactly what the committed lockfile records.
|
|
115
|
+
* Inputs are validated before npm runs — the appliance allowlist, the
|
|
116
|
+
* workspace manifest against the declarations, and the lockfile against the
|
|
117
|
+
* manifest — and neither tracked file is ever rewritten. An installed tree is
|
|
118
|
+
* reused only when it matches the lockfile's versions and integrity.
|
|
119
|
+
*/
|
|
120
|
+
export async function installDeclaredPluginsFrozen(
|
|
121
|
+
companionPath: string,
|
|
122
|
+
declarations: PluginDeclaration[],
|
|
123
|
+
deps: FrozenInstallDeps = {},
|
|
124
|
+
): Promise<PluginInstallResult[]> {
|
|
125
|
+
const workspaceRoot = dynamicPluginsWorkspaceRoot(companionPath);
|
|
126
|
+
const sorted = declarations.toSorted((a, b) => a.package.localeCompare(b.package));
|
|
127
|
+
const desired: Record<string, string> = {};
|
|
128
|
+
for (const declaration of sorted) desired[declaration.package] = declaration.version;
|
|
129
|
+
|
|
130
|
+
let policy: ReturnType<typeof readPluginPolicy>;
|
|
131
|
+
try {
|
|
132
|
+
policy = readPluginPolicy(deps.env ?? process.env);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof PluginPolicyError) throw new FrozenInstallError(error.message);
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
const refused = sorted.filter((declaration) => !isPluginAllowed(policy, declaration.package));
|
|
138
|
+
if (refused.length > 0) {
|
|
139
|
+
throw new FrozenInstallError(
|
|
140
|
+
refused.map((declaration) => disallowedPluginMessage(declaration.package)).join("\n"),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const manifestFile = path.join(workspaceRoot, "package.json");
|
|
145
|
+
const lockFile = path.join(workspaceRoot, "package-lock.json");
|
|
146
|
+
const [manifestText, lockText] = await Promise.all([readText(manifestFile), readText(lockFile)]);
|
|
147
|
+
if (manifestText === null) {
|
|
148
|
+
throw new FrozenInstallError(
|
|
149
|
+
`${manifestFile} is missing; commit the plugin workspace manifest in the companion's authoring flow`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (lockText === null) {
|
|
153
|
+
throw new FrozenInstallError(
|
|
154
|
+
`${lockFile} is missing; commit the plugin lockfile in the companion's authoring flow`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const manifest = parseJson<{ dependencies?: Record<string, string> }>(manifestText, manifestFile);
|
|
159
|
+
const manifestDrift = sameMap(desired, manifest.dependencies ?? {});
|
|
160
|
+
if (manifestDrift.length > 0) {
|
|
161
|
+
throw new FrozenInstallError(
|
|
162
|
+
`plugin workspace manifest disagrees with framework.yaml: ${manifestDrift.join("; ")}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const lock = parseJson<Lockfile>(lockText, lockFile);
|
|
166
|
+
const lockRoot = lock.packages?.[""]?.dependencies ?? {};
|
|
167
|
+
const lockDrift = sameMap(desired, lockRoot);
|
|
168
|
+
if (lockDrift.length > 0) {
|
|
169
|
+
throw new FrozenInstallError(
|
|
170
|
+
`plugin lockfile disagrees with framework.yaml: ${lockDrift.join("; ")}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const unlocked = sorted.filter((declaration) => {
|
|
174
|
+
const entry = lock.packages?.[`node_modules/${declaration.package}`];
|
|
175
|
+
return !entry?.version || !entry.integrity;
|
|
176
|
+
});
|
|
177
|
+
if (unlocked.length > 0) {
|
|
178
|
+
throw new FrozenInstallError(
|
|
179
|
+
`plugin lockfile has no resolved version and integrity for: ${unlocked.map((d) => d.package).join(", ")}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const lockedVersion = (name: string) => lock.packages?.[`node_modules/${name}`]?.version;
|
|
184
|
+
const fail = (error: string): PluginInstallResult[] =>
|
|
185
|
+
sorted.map((declaration) => ({ package: declaration.package, status: "failed", error }));
|
|
186
|
+
|
|
187
|
+
if ((await treeMismatches(workspaceRoot, lock)).length === 0) {
|
|
188
|
+
return sorted.map((declaration) => ({
|
|
189
|
+
package: declaration.package,
|
|
190
|
+
status: "unchanged",
|
|
191
|
+
resolvedVersion: lockedVersion(declaration.package),
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const outcome = await (deps.runNpmCi ?? defaultNpmCi)(workspaceRoot);
|
|
196
|
+
if (!outcome.ok) return fail(outcome.detail ?? "npm ci failed");
|
|
197
|
+
|
|
198
|
+
const [manifestAfter, lockAfter] = await Promise.all([
|
|
199
|
+
readText(manifestFile),
|
|
200
|
+
readText(lockFile),
|
|
201
|
+
]);
|
|
202
|
+
if (manifestAfter !== manifestText || lockAfter !== lockText) {
|
|
203
|
+
await Promise.all([fs.writeFile(manifestFile, manifestText), fs.writeFile(lockFile, lockText)]);
|
|
204
|
+
return fail("the restore changed the committed manifest or lockfile; they were put back");
|
|
205
|
+
}
|
|
206
|
+
const remaining = await treeMismatches(workspaceRoot, lock);
|
|
207
|
+
if (remaining.length > 0) {
|
|
208
|
+
return fail(`installed tree does not match the lockfile: ${remaining.join("; ")}`);
|
|
209
|
+
}
|
|
210
|
+
return sorted.map((declaration) => ({
|
|
211
|
+
package: declaration.package,
|
|
212
|
+
status: "installed",
|
|
213
|
+
resolvedVersion: lockedVersion(declaration.package),
|
|
214
|
+
}));
|
|
215
|
+
}
|