@yagni-app/code-staging 1.2.4-staging.1778.1 → 1.2.4-staging.1809.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/dist/agentSources.d.ts +78 -0
- package/dist/agentSources.js +194 -0
- package/dist/claudeCompat.d.ts +83 -0
- package/dist/claudeCompat.js +234 -14
- package/dist/claudePlugins.d.ts +15 -1
- package/dist/claudePlugins.js +12 -4
- package/dist/extension/pipeline/headlessGo.d.ts +5 -0
- package/dist/extension/pipeline/headlessGo.js +1 -0
- package/dist/extension/pipeline/invocation.d.ts +1 -0
- package/dist/extension/pipeline/invocation.js +8 -1
- package/dist/extension/pipeline/orchestrator.d.ts +8 -0
- package/dist/extension/pipeline/orchestrator.js +9 -7
- package/dist/extension/pipeline/runner.d.ts +6 -0
- package/dist/extension/pipeline/runner.js +5 -1
- package/dist/goHeadless.d.ts +24 -0
- package/dist/goHeadless.js +107 -1
- package/package.json +2 -2
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cloud run's skill sources (YAG-784): the mission file `sources.json` the
|
|
3
|
+
* backend writes beside `ticket.md`, and the compat options it turns into.
|
|
4
|
+
*
|
|
5
|
+
* The backend decides everything here: whether the working repo is trusted
|
|
6
|
+
* for agent runs (an admin's per-repo flag, standing in for pi's trust.json),
|
|
7
|
+
* and which org marketplace and personal skills snapshots it fetched and
|
|
8
|
+
* extracted under AGENT_SOURCES_ROOT. The CLI only reads the file, so it
|
|
9
|
+
* carries its own small validator rather than importing @yagni/shared (this
|
|
10
|
+
* package never takes a build-time dependency on a private workspace package).
|
|
11
|
+
* The contract is `AgentRunSourcesFile` in packages/shared/src/agentSources.ts.
|
|
12
|
+
*
|
|
13
|
+
* Everything is fail-soft: a missing, oversized, or garbled file means "no
|
|
14
|
+
* cloud sources" (the caller falls back to local behavior), and one bad
|
|
15
|
+
* source is dropped without touching the others.
|
|
16
|
+
*/
|
|
17
|
+
import type { CompatExtraRoot, CompatMarketplace } from "./claudeCompat.js";
|
|
18
|
+
/** Where the backend extracts the org and personal snapshots in the sandbox. */
|
|
19
|
+
export declare const AGENT_SOURCES_ROOT = "/opt/yagni/sources";
|
|
20
|
+
export declare const AGENT_RUN_SOURCES_FILE_NAME = "sources.json";
|
|
21
|
+
/** pi-harness.sh's default when YAGNI_MISSION_DIR is unset. */
|
|
22
|
+
export declare const DEFAULT_MISSION_DIR = "/opt/yagni/mission";
|
|
23
|
+
export interface AgentRunSources {
|
|
24
|
+
repo: {
|
|
25
|
+
fullName: string;
|
|
26
|
+
sha: string;
|
|
27
|
+
trusted: boolean;
|
|
28
|
+
};
|
|
29
|
+
org: {
|
|
30
|
+
fullName: string;
|
|
31
|
+
ref: string | null;
|
|
32
|
+
sha: string;
|
|
33
|
+
dir: string;
|
|
34
|
+
enabledPlugins: string[];
|
|
35
|
+
} | null;
|
|
36
|
+
personal: {
|
|
37
|
+
fullName: string;
|
|
38
|
+
ref: string | null;
|
|
39
|
+
sha: string;
|
|
40
|
+
dir: string;
|
|
41
|
+
userId: string;
|
|
42
|
+
} | null;
|
|
43
|
+
}
|
|
44
|
+
/** Validate a parsed sources.json (version 1). Null when it isn't one. */
|
|
45
|
+
export declare function parseAgentRunSources(raw: unknown): AgentRunSources | null;
|
|
46
|
+
/**
|
|
47
|
+
* Read `<missionDir>/sources.json`. Null when absent, too large, or invalid.
|
|
48
|
+
* Never throws. A file that is there but unusable is not the same as no file:
|
|
49
|
+
* the backend already recorded its sources as loaded, so `warn` says so (one
|
|
50
|
+
* stderr line in a headless run) rather than degrade to local mode silently.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readAgentRunSources(missionDir: string, warn?: (line: string) => void): AgentRunSources | null;
|
|
53
|
+
/** The mission dir the harness wrote into (`YAGNI_MISSION_DIR`, else its default). */
|
|
54
|
+
export declare function missionDirFromEnv(env: NodeJS.ProcessEnv): string;
|
|
55
|
+
/**
|
|
56
|
+
* `dir` as a realpath when it is absolute, an existing directory, and inside
|
|
57
|
+
* `sourcesRoot` after symlink resolution; else the reason it was refused.
|
|
58
|
+
*/
|
|
59
|
+
export declare function checkSourceDir(dir: string, sourcesRoot: string): {
|
|
60
|
+
dir: string;
|
|
61
|
+
} | {
|
|
62
|
+
refused: string;
|
|
63
|
+
};
|
|
64
|
+
/** What a valid sources file asks `claudeCompatArgs` to load. */
|
|
65
|
+
export interface CloudCompatOptions {
|
|
66
|
+
trustOverride: boolean;
|
|
67
|
+
extraRoots: CompatExtraRoot[];
|
|
68
|
+
marketplaces: CompatMarketplace[];
|
|
69
|
+
/** `label: reason` for each source dir that was refused. */
|
|
70
|
+
refused: string[];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Turn a sources file into compat options. Snapshot dirs must sit under
|
|
74
|
+
* `sourcesRoot` (AGENT_SOURCES_ROOT in the sandbox); any other dir is refused
|
|
75
|
+
* and reported, never loaded.
|
|
76
|
+
*/
|
|
77
|
+
export declare function cloudCompatOptions(sources: AgentRunSources, sourcesRoot?: string): CloudCompatOptions;
|
|
78
|
+
//# sourceMappingURL=agentSources.d.ts.map
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cloud run's skill sources (YAG-784): the mission file `sources.json` the
|
|
3
|
+
* backend writes beside `ticket.md`, and the compat options it turns into.
|
|
4
|
+
*
|
|
5
|
+
* The backend decides everything here: whether the working repo is trusted
|
|
6
|
+
* for agent runs (an admin's per-repo flag, standing in for pi's trust.json),
|
|
7
|
+
* and which org marketplace and personal skills snapshots it fetched and
|
|
8
|
+
* extracted under AGENT_SOURCES_ROOT. The CLI only reads the file, so it
|
|
9
|
+
* carries its own small validator rather than importing @yagni/shared (this
|
|
10
|
+
* package never takes a build-time dependency on a private workspace package).
|
|
11
|
+
* The contract is `AgentRunSourcesFile` in packages/shared/src/agentSources.ts.
|
|
12
|
+
*
|
|
13
|
+
* Everything is fail-soft: a missing, oversized, or garbled file means "no
|
|
14
|
+
* cloud sources" (the caller falls back to local behavior), and one bad
|
|
15
|
+
* source is dropped without touching the others.
|
|
16
|
+
*/
|
|
17
|
+
import { closeSync, fstatSync, openSync, readSync, realpathSync, statSync } from "node:fs";
|
|
18
|
+
import { isAbsolute, join, sep } from "node:path";
|
|
19
|
+
/** Where the backend extracts the org and personal snapshots in the sandbox. */
|
|
20
|
+
export const AGENT_SOURCES_ROOT = "/opt/yagni/sources";
|
|
21
|
+
export const AGENT_RUN_SOURCES_FILE_NAME = "sources.json";
|
|
22
|
+
/** pi-harness.sh's default when YAGNI_MISSION_DIR is unset. */
|
|
23
|
+
export const DEFAULT_MISSION_DIR = "/opt/yagni/mission";
|
|
24
|
+
/** A sources file is a few hundred bytes; anything past this is not one. */
|
|
25
|
+
const MAX_SOURCES_FILE_BYTES = 64 * 1024;
|
|
26
|
+
/** Mirrors MAX_ENABLED_PLUGINS in the shared contract. */
|
|
27
|
+
const MAX_ENABLED_PLUGINS = 50;
|
|
28
|
+
/**
|
|
29
|
+
* A label, never a path. The owner allows `_` because a working repo can
|
|
30
|
+
* belong to an Enterprise Managed User (`jane_acme`); rejecting it would drop
|
|
31
|
+
* the whole file and silently ignore the repo's trust flag.
|
|
32
|
+
*/
|
|
33
|
+
const FULL_NAME = /^[A-Za-z0-9_-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
34
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
35
|
+
/** Same shape as claudePlugins' SAFE_SEGMENT: plugin names are path segments. */
|
|
36
|
+
const PLUGIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
37
|
+
function isRecord(value) {
|
|
38
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
function refOf(value) {
|
|
41
|
+
if (value === undefined || value === null)
|
|
42
|
+
return null;
|
|
43
|
+
return typeof value === "string" && value.length > 0 && value.length <= 255 ? value : undefined;
|
|
44
|
+
}
|
|
45
|
+
function snapshotBase(value) {
|
|
46
|
+
if (!isRecord(value))
|
|
47
|
+
return null;
|
|
48
|
+
const { fullName, sha, dir } = value;
|
|
49
|
+
const ref = refOf(value.ref);
|
|
50
|
+
if (typeof fullName !== "string" || !FULL_NAME.test(fullName))
|
|
51
|
+
return null;
|
|
52
|
+
if (typeof sha !== "string" || !SHA.test(sha))
|
|
53
|
+
return null;
|
|
54
|
+
if (typeof dir !== "string" || dir.length === 0)
|
|
55
|
+
return null;
|
|
56
|
+
if (ref === undefined)
|
|
57
|
+
return null;
|
|
58
|
+
return { fullName, ref, sha, dir };
|
|
59
|
+
}
|
|
60
|
+
/** Validate a parsed sources.json (version 1). Null when it isn't one. */
|
|
61
|
+
export function parseAgentRunSources(raw) {
|
|
62
|
+
if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.repo))
|
|
63
|
+
return null;
|
|
64
|
+
const { fullName, sha, trusted } = raw.repo;
|
|
65
|
+
if (typeof fullName !== "string" || !FULL_NAME.test(fullName))
|
|
66
|
+
return null;
|
|
67
|
+
if (typeof sha !== "string" || !SHA.test(sha))
|
|
68
|
+
return null;
|
|
69
|
+
if (typeof trusted !== "boolean")
|
|
70
|
+
return null;
|
|
71
|
+
let org = null;
|
|
72
|
+
const orgBase = snapshotBase(raw.org);
|
|
73
|
+
if (orgBase && isRecord(raw.org) && Array.isArray(raw.org.enabledPlugins)) {
|
|
74
|
+
const enabledPlugins = [
|
|
75
|
+
...new Set(raw.org.enabledPlugins.filter((p) => typeof p === "string" && PLUGIN_NAME.test(p))),
|
|
76
|
+
].slice(0, MAX_ENABLED_PLUGINS);
|
|
77
|
+
org = { ...orgBase, enabledPlugins };
|
|
78
|
+
}
|
|
79
|
+
let personal = null;
|
|
80
|
+
const personalBase = snapshotBase(raw.personal);
|
|
81
|
+
if (personalBase && isRecord(raw.personal) && typeof raw.personal.userId === "string") {
|
|
82
|
+
personal = { ...personalBase, userId: raw.personal.userId };
|
|
83
|
+
}
|
|
84
|
+
return { repo: { fullName, sha, trusted }, org, personal };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Read `<missionDir>/sources.json`. Null when absent, too large, or invalid.
|
|
88
|
+
* Never throws. A file that is there but unusable is not the same as no file:
|
|
89
|
+
* the backend already recorded its sources as loaded, so `warn` says so (one
|
|
90
|
+
* stderr line in a headless run) rather than degrade to local mode silently.
|
|
91
|
+
*/
|
|
92
|
+
export function readAgentRunSources(missionDir, warn = () => { }) {
|
|
93
|
+
const path = join(missionDir, AGENT_RUN_SOURCES_FILE_NAME);
|
|
94
|
+
let fd = null;
|
|
95
|
+
try {
|
|
96
|
+
try {
|
|
97
|
+
fd = openSync(path, "r");
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err?.code === "ENOENT")
|
|
101
|
+
return null;
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
const size = fstatSync(fd).size;
|
|
105
|
+
if (size === 0 || size > MAX_SOURCES_FILE_BYTES) {
|
|
106
|
+
warn(`Skills: ${path} ignored (${size === 0 ? "empty" : `larger than ${MAX_SOURCES_FILE_BYTES} bytes`}); no skills sources loaded`);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const buffer = Buffer.alloc(size);
|
|
110
|
+
readSync(fd, buffer, 0, size, 0);
|
|
111
|
+
const parsed = parseAgentRunSources(JSON.parse(buffer.toString("utf8")));
|
|
112
|
+
if (!parsed)
|
|
113
|
+
warn(`Skills: ${path} ignored (not a valid version 1 sources file); no skills sources loaded`);
|
|
114
|
+
return parsed;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const why = err instanceof SyntaxError ? "not valid JSON" : err instanceof Error ? err.message : String(err);
|
|
118
|
+
warn(`Skills: ${path} ignored (${why}); no skills sources loaded`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
if (fd !== null) {
|
|
123
|
+
try {
|
|
124
|
+
closeSync(fd);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Already closed.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** The mission dir the harness wrote into (`YAGNI_MISSION_DIR`, else its default). */
|
|
133
|
+
export function missionDirFromEnv(env) {
|
|
134
|
+
const dir = env.YAGNI_MISSION_DIR?.trim();
|
|
135
|
+
return dir ? dir : DEFAULT_MISSION_DIR;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* `dir` as a realpath when it is absolute, an existing directory, and inside
|
|
139
|
+
* `sourcesRoot` after symlink resolution; else the reason it was refused.
|
|
140
|
+
*/
|
|
141
|
+
export function checkSourceDir(dir, sourcesRoot) {
|
|
142
|
+
if (!isAbsolute(dir))
|
|
143
|
+
return { refused: "not an absolute path" };
|
|
144
|
+
let real;
|
|
145
|
+
let realRoot;
|
|
146
|
+
try {
|
|
147
|
+
real = realpathSync(dir);
|
|
148
|
+
realRoot = realpathSync(sourcesRoot);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return { refused: "not present in the sandbox" };
|
|
152
|
+
}
|
|
153
|
+
if (!real.startsWith(realRoot + sep))
|
|
154
|
+
return { refused: `outside ${sourcesRoot}` };
|
|
155
|
+
try {
|
|
156
|
+
if (!statSync(real).isDirectory())
|
|
157
|
+
return { refused: "not a directory" };
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return { refused: "not present in the sandbox" };
|
|
161
|
+
}
|
|
162
|
+
return { dir: real };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Turn a sources file into compat options. Snapshot dirs must sit under
|
|
166
|
+
* `sourcesRoot` (AGENT_SOURCES_ROOT in the sandbox); any other dir is refused
|
|
167
|
+
* and reported, never loaded.
|
|
168
|
+
*/
|
|
169
|
+
export function cloudCompatOptions(sources, sourcesRoot = AGENT_SOURCES_ROOT) {
|
|
170
|
+
const out = {
|
|
171
|
+
trustOverride: sources.repo.trusted,
|
|
172
|
+
extraRoots: [],
|
|
173
|
+
marketplaces: [],
|
|
174
|
+
refused: [],
|
|
175
|
+
};
|
|
176
|
+
if (sources.org) {
|
|
177
|
+
const label = `${sources.org.fullName} @ ${sources.org.sha.slice(0, 7)}`;
|
|
178
|
+
const checked = checkSourceDir(sources.org.dir, sourcesRoot);
|
|
179
|
+
if ("dir" in checked)
|
|
180
|
+
out.marketplaces.push({ root: checked.dir, enabled: sources.org.enabledPlugins, label });
|
|
181
|
+
else
|
|
182
|
+
out.refused.push(`${label}: ${checked.refused}`);
|
|
183
|
+
}
|
|
184
|
+
if (sources.personal) {
|
|
185
|
+
const label = `${sources.personal.fullName} @ ${sources.personal.sha.slice(0, 7)}`;
|
|
186
|
+
const checked = checkSourceDir(sources.personal.dir, sourcesRoot);
|
|
187
|
+
if ("dir" in checked)
|
|
188
|
+
out.extraRoots.push({ root: checked.dir, label });
|
|
189
|
+
else
|
|
190
|
+
out.refused.push(`${label}: ${checked.refused}`);
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=agentSources.js.map
|
package/dist/claudeCompat.d.ts
CHANGED
|
@@ -107,6 +107,60 @@ export declare function readTrustDecision(agentDir: string, cwd: string): boolea
|
|
|
107
107
|
* asked again next launch.
|
|
108
108
|
*/
|
|
109
109
|
export declare function writeTrustDecision(agentDir: string, cwd: string, decision: boolean): void;
|
|
110
|
+
/**
|
|
111
|
+
* A personal skills root (a person's own skills repository, extracted into the
|
|
112
|
+
* sandbox). Any of the layouts in EXTRA_ROOT_LAYOUT that exist load,
|
|
113
|
+
* regardless of the working repo's trust: the backend only hands over a root
|
|
114
|
+
* it authorized.
|
|
115
|
+
*/
|
|
116
|
+
export interface CompatExtraRoot {
|
|
117
|
+
root: string;
|
|
118
|
+
/** Human label for logs, e.g. `jane/skills`. */
|
|
119
|
+
label: string;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A plugin marketplace checkout (`<root>/.claude-plugin/marketplace.json`) of
|
|
123
|
+
* which only the `enabled` plugins load. Only relative-path plugin sources are
|
|
124
|
+
* supported; object sources (github, url, npm) are reported, never fetched.
|
|
125
|
+
*/
|
|
126
|
+
export interface CompatMarketplace {
|
|
127
|
+
root: string;
|
|
128
|
+
enabled: string[];
|
|
129
|
+
label: string;
|
|
130
|
+
}
|
|
131
|
+
/** Personal root layouts, in load order. */
|
|
132
|
+
export declare const EXTRA_ROOT_LAYOUT: {
|
|
133
|
+
readonly skills: readonly ["skills", ".claude/skills", ".agents/skills"];
|
|
134
|
+
readonly commands: readonly ["commands", ".claude/commands"];
|
|
135
|
+
readonly rules: readonly ["rules", ".claude/rules"];
|
|
136
|
+
};
|
|
137
|
+
/** Bound on the symlink scan of one asset path, so a huge tree can't stall a launch. */
|
|
138
|
+
export declare const SYMLINK_SCAN_LIMIT = 20000;
|
|
139
|
+
/**
|
|
140
|
+
* `<root>/<rel>` as a realpath when it exists, is a directory, stays inside
|
|
141
|
+
* `root` after symlink resolution, and holds no symlink pointing outside it.
|
|
142
|
+
* Every source the interactive launcher never read goes through this, so a
|
|
143
|
+
* hostile link in a repo or snapshot can't hand pi a directory outside it.
|
|
144
|
+
* `scanLimit` exists for tests: a tree with more entries than it is refused.
|
|
145
|
+
*/
|
|
146
|
+
export declare function safeSourceDir(root: string, rel: string, scanLimit?: number): string | null;
|
|
147
|
+
/** One source's contribution, summarized for the launcher's log line. */
|
|
148
|
+
export interface CompatSourceSummary {
|
|
149
|
+
label: string;
|
|
150
|
+
kind: "user" | "project" | "personal" | "org-marketplace";
|
|
151
|
+
skills: string[];
|
|
152
|
+
commands: string[];
|
|
153
|
+
rulesDirs: string[];
|
|
154
|
+
/** `name@marketplace` keys of the plugins it contributed. */
|
|
155
|
+
plugins: string[];
|
|
156
|
+
/** Things present but not loaded (unsupported plugin parts, missing plugins). */
|
|
157
|
+
unsupported: string[];
|
|
158
|
+
}
|
|
159
|
+
/** The enabled plugins of one marketplace checkout, plus what could not load. Never throws. */
|
|
160
|
+
export declare function marketplacePlugins(mp: CompatMarketplace): {
|
|
161
|
+
plugins: PluginAssets[];
|
|
162
|
+
unsupported: string[];
|
|
163
|
+
};
|
|
110
164
|
export interface ClaudeCompatDeps {
|
|
111
165
|
cwd: string;
|
|
112
166
|
/** The hermetic pi agent dir for the active profile (holds trust.json). */
|
|
@@ -116,6 +170,26 @@ export interface ClaudeCompatDeps {
|
|
|
116
170
|
interactive?: boolean;
|
|
117
171
|
/** The one-time trust question; injectable for tests. */
|
|
118
172
|
confirm?: (question: string) => Promise<boolean>;
|
|
173
|
+
/**
|
|
174
|
+
* A trust decision made elsewhere (a cloud run's per-repo admin flag). It
|
|
175
|
+
* replaces the trust.json lookup and the prompt, and is never written back.
|
|
176
|
+
*/
|
|
177
|
+
trustOverride?: boolean;
|
|
178
|
+
/** False skips `~/.claude`, home plugins and the YAGNI ledger. Default true. */
|
|
179
|
+
userScope?: boolean;
|
|
180
|
+
/** False never emits YAGNI_CLAUDE_PLUGIN_MCP (headless runs). Default true. */
|
|
181
|
+
pluginMcp?: boolean;
|
|
182
|
+
/**
|
|
183
|
+
* Once the folder is trusted, also wire `<cwd>/.agents/skills`, `.pi/skills`
|
|
184
|
+
* (`--skill`) and `.pi/prompts` (`--prompt-template`) explicitly, so a
|
|
185
|
+
* headless run never needs pi's `--approve` (which would also run the repo's
|
|
186
|
+
* `.pi/extensions` code). Default false.
|
|
187
|
+
*/
|
|
188
|
+
projectNativeSkills?: boolean;
|
|
189
|
+
/** Personal skills roots; load regardless of the folder's trust. */
|
|
190
|
+
extraRoots?: CompatExtraRoot[];
|
|
191
|
+
/** Plugin marketplaces with an explicit enabled list; load regardless of trust. */
|
|
192
|
+
marketplaces?: CompatMarketplace[];
|
|
119
193
|
}
|
|
120
194
|
export declare function compatDisabled(env: NodeJS.ProcessEnv): boolean;
|
|
121
195
|
export interface ClaudeCompatLaunch {
|
|
@@ -123,12 +197,21 @@ export interface ClaudeCompatLaunch {
|
|
|
123
197
|
argv: string[];
|
|
124
198
|
/** Extra child env (plugin agent dirs, rules dirs) for the extension. */
|
|
125
199
|
env: Record<string, string>;
|
|
200
|
+
/**
|
|
201
|
+
* What each source contributed, for logging. Absent when nothing loaded and
|
|
202
|
+
* no extra source was asked for.
|
|
203
|
+
*/
|
|
204
|
+
sources?: CompatSourceSummary[];
|
|
126
205
|
}
|
|
127
206
|
/**
|
|
128
207
|
* The extra pi argv + child env for this launch: user-scope dirs and plugins
|
|
129
208
|
* unconditionally, project-sourced dirs/plugins/rules once trusted, asking
|
|
130
209
|
* (and persisting) the trust decision when needed. Everything is fail-soft —
|
|
131
210
|
* compat can only add flags/env, never break a launch.
|
|
211
|
+
*
|
|
212
|
+
* The optional deps (`trustOverride`, `userScope`, `pluginMcp`,
|
|
213
|
+
* `projectNativeSkills`, `extraRoots`, `marketplaces`) serve headless cloud
|
|
214
|
+
* runs; left unset, the argv and env are exactly the interactive behavior.
|
|
132
215
|
*/
|
|
133
216
|
export declare function claudeCompatArgs(deps: ClaudeCompatDeps): Promise<ClaudeCompatLaunch>;
|
|
134
217
|
/** One plugin as the in-session `/plugin` panel sees it. */
|
package/dist/claudeCompat.js
CHANGED
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
*
|
|
29
29
|
* Set YAGNI_DISABLE_CLAUDE_COMPAT=1 to turn the whole bridge off.
|
|
30
30
|
*/
|
|
31
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync, } from "node:fs";
|
|
32
32
|
import { homedir } from "node:os";
|
|
33
|
-
import { delimiter, dirname, join, resolve } from "node:path";
|
|
34
|
-
import { discoverClaudePlugins } from "./claudePlugins.js";
|
|
33
|
+
import { delimiter, dirname, join, resolve, sep } from "node:path";
|
|
34
|
+
import { containedExistingPath, discoverClaudePlugins, pluginAssets, readMarketplace, resolveLocalPluginRoot, } from "./claudePlugins.js";
|
|
35
35
|
import { pluginsHome, readLedger } from "./pluginStore.js";
|
|
36
36
|
export const CLAUDE_COMPAT_DISABLE_ENV = "YAGNI_DISABLE_CLAUDE_COMPAT";
|
|
37
37
|
/** Plugin `agents/` dirs for the extension's subagent discovery (delimiter-joined). */
|
|
@@ -241,6 +241,137 @@ export function writeTrustDecision(agentDir, cwd, decision) {
|
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
|
+
/** Personal root layouts, in load order. */
|
|
245
|
+
export const EXTRA_ROOT_LAYOUT = {
|
|
246
|
+
skills: ["skills", ".claude/skills", ".agents/skills"],
|
|
247
|
+
commands: ["commands", ".claude/commands"],
|
|
248
|
+
rules: ["rules", ".claude/rules"],
|
|
249
|
+
};
|
|
250
|
+
/** Project dirs pi loads only behind its own trust; wired explicitly instead. */
|
|
251
|
+
const PROJECT_NATIVE_SKILLS = [".agents/skills", ".pi/skills"];
|
|
252
|
+
const PROJECT_NATIVE_PROMPTS = [".pi/prompts"];
|
|
253
|
+
/** Bound on the symlink scan of one asset path, so a huge tree can't stall a launch. */
|
|
254
|
+
export const SYMLINK_SCAN_LIMIT = 20_000;
|
|
255
|
+
function isContainedIn(realRoot, real) {
|
|
256
|
+
return real === realRoot || real.startsWith(realRoot + sep);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* True when `path` (inside `realRoot`) holds no symlink that resolves outside
|
|
260
|
+
* `realRoot`. A tree too large to scan within the bound counts as unsafe.
|
|
261
|
+
*/
|
|
262
|
+
function symlinksStayInside(path, realRoot, scanLimit = SYMLINK_SCAN_LIMIT) {
|
|
263
|
+
let budget = scanLimit;
|
|
264
|
+
const stack = [path];
|
|
265
|
+
while (stack.length > 0) {
|
|
266
|
+
const current = stack.pop();
|
|
267
|
+
let entries;
|
|
268
|
+
try {
|
|
269
|
+
if (!lstatSync(current).isDirectory())
|
|
270
|
+
continue;
|
|
271
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
for (const entry of entries) {
|
|
277
|
+
if (--budget < 0)
|
|
278
|
+
return false;
|
|
279
|
+
const child = join(current, entry.name);
|
|
280
|
+
if (entry.isSymbolicLink()) {
|
|
281
|
+
let real;
|
|
282
|
+
try {
|
|
283
|
+
real = realpathSync(child);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
continue; // Dangling: nothing can be read through it.
|
|
287
|
+
}
|
|
288
|
+
if (!isContainedIn(realRoot, real))
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
else if (entry.isDirectory()) {
|
|
292
|
+
stack.push(child);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* `<root>/<rel>` as a realpath when it exists, is a directory, stays inside
|
|
300
|
+
* `root` after symlink resolution, and holds no symlink pointing outside it.
|
|
301
|
+
* Every source the interactive launcher never read goes through this, so a
|
|
302
|
+
* hostile link in a repo or snapshot can't hand pi a directory outside it.
|
|
303
|
+
* `scanLimit` exists for tests: a tree with more entries than it is refused.
|
|
304
|
+
*/
|
|
305
|
+
export function safeSourceDir(root, rel, scanLimit = SYMLINK_SCAN_LIMIT) {
|
|
306
|
+
const contained = containedExistingPath(root, rel);
|
|
307
|
+
if (!contained || !isDirectory(contained))
|
|
308
|
+
return null;
|
|
309
|
+
let realRoot;
|
|
310
|
+
try {
|
|
311
|
+
realRoot = realpathSync(root);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
return symlinksStayInside(contained, realRoot, scanLimit) ? contained : null;
|
|
317
|
+
}
|
|
318
|
+
function emptySummary(label, kind) {
|
|
319
|
+
return { label, kind, skills: [], commands: [], rulesDirs: [], plugins: [], unsupported: [] };
|
|
320
|
+
}
|
|
321
|
+
function summaryHasContent(s) {
|
|
322
|
+
return s.skills.length + s.commands.length + s.rulesDirs.length + s.plugins.length + s.unsupported.length > 0;
|
|
323
|
+
}
|
|
324
|
+
function addPluginsToSummary(summary, plugins) {
|
|
325
|
+
for (const plugin of plugins) {
|
|
326
|
+
summary.plugins.push(plugin.key);
|
|
327
|
+
summary.skills.push(...plugin.skillPaths);
|
|
328
|
+
summary.commands.push(...plugin.commandPaths);
|
|
329
|
+
summary.unsupported.push(...plugin.unsupported.map((u) => `${plugin.key}: ${u}`));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** The enabled plugins of one marketplace checkout, plus what could not load. Never throws. */
|
|
333
|
+
export function marketplacePlugins(mp) {
|
|
334
|
+
const marketplace = readMarketplace(mp.root);
|
|
335
|
+
let realRoot = null;
|
|
336
|
+
try {
|
|
337
|
+
realRoot = realpathSync(mp.root);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
realRoot = null;
|
|
341
|
+
}
|
|
342
|
+
if (!marketplace || !realRoot) {
|
|
343
|
+
return { plugins: [], unsupported: [".claude-plugin/marketplace.json missing or unreadable"] };
|
|
344
|
+
}
|
|
345
|
+
const plugins = [];
|
|
346
|
+
const unsupported = [];
|
|
347
|
+
for (const name of new Set(mp.enabled)) {
|
|
348
|
+
const entry = marketplace.plugins.find((p) => p.name === name);
|
|
349
|
+
if (!entry) {
|
|
350
|
+
unsupported.push(`${name}: not in the marketplace`);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const root = resolveLocalPluginRoot(marketplace, entry);
|
|
354
|
+
if (!root) {
|
|
355
|
+
unsupported.push(`${name}: plugin source not supported`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (!symlinksStayInside(root, realRoot)) {
|
|
359
|
+
unsupported.push(`${name}: links outside the marketplace`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const assets = pluginAssets(root, name, {
|
|
363
|
+
key: `${name}@${marketplace.name}`,
|
|
364
|
+
origin: "org-marketplace",
|
|
365
|
+
version: entry.version ?? null,
|
|
366
|
+
});
|
|
367
|
+
if (!assets) {
|
|
368
|
+
unsupported.push(`${name}: nothing to load`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
plugins.push(assets);
|
|
372
|
+
}
|
|
373
|
+
return { plugins, unsupported };
|
|
374
|
+
}
|
|
244
375
|
export function compatDisabled(env) {
|
|
245
376
|
const value = env[CLAUDE_COMPAT_DISABLE_ENV];
|
|
246
377
|
return value !== undefined && value !== "" && value !== "0";
|
|
@@ -250,27 +381,34 @@ export function compatDisabled(env) {
|
|
|
250
381
|
* unconditionally, project-sourced dirs/plugins/rules once trusted, asking
|
|
251
382
|
* (and persisting) the trust decision when needed. Everything is fail-soft —
|
|
252
383
|
* compat can only add flags/env, never break a launch.
|
|
384
|
+
*
|
|
385
|
+
* The optional deps (`trustOverride`, `userScope`, `pluginMcp`,
|
|
386
|
+
* `projectNativeSkills`, `extraRoots`, `marketplaces`) serve headless cloud
|
|
387
|
+
* runs; left unset, the argv and env are exactly the interactive behavior.
|
|
253
388
|
*/
|
|
254
389
|
export async function claudeCompatArgs(deps) {
|
|
255
390
|
const env = deps.env ?? process.env;
|
|
256
391
|
if (compatDisabled(env))
|
|
257
392
|
return { argv: [], env: {} };
|
|
258
393
|
const home = deps.homeDir ?? homedir();
|
|
259
|
-
const
|
|
394
|
+
const userScope = deps.userScope !== false;
|
|
395
|
+
const projectTrust = deps.trustOverride ?? readTrustDecision(deps.agentDir, deps.cwd);
|
|
260
396
|
let plugins;
|
|
261
397
|
try {
|
|
262
398
|
plugins = discoverClaudePlugins({
|
|
263
399
|
cwd: deps.cwd,
|
|
264
400
|
homeDir: home,
|
|
265
|
-
yagniLedger: readLedger(pluginsHome(env, home)),
|
|
401
|
+
...(userScope ? { yagniLedger: readLedger(pluginsHome(env, home)) } : { includeUserScope: false }),
|
|
266
402
|
});
|
|
267
403
|
}
|
|
268
404
|
catch {
|
|
269
405
|
plugins = { user: [], project: [] };
|
|
270
406
|
}
|
|
407
|
+
const projectDirs = probeClaudeDirs(deps.cwd);
|
|
408
|
+
const userDirs = userScope ? probeClaudeDirs(home) : { skills: null, commands: null, rules: null };
|
|
271
409
|
const decision = decideClaudeCompat({
|
|
272
|
-
project:
|
|
273
|
-
user:
|
|
410
|
+
project: projectDirs,
|
|
411
|
+
user: userDirs,
|
|
274
412
|
projectPlugins: plugins.project,
|
|
275
413
|
userPlugins: plugins.user,
|
|
276
414
|
projectTrust,
|
|
@@ -286,27 +424,106 @@ export async function claudeCompatArgs(deps) {
|
|
|
286
424
|
trusted = null;
|
|
287
425
|
}
|
|
288
426
|
}
|
|
289
|
-
|
|
290
|
-
|
|
427
|
+
// Backend-authorized sources (the org marketplace's enabled plugins and the
|
|
428
|
+
// personal roots) load whatever the folder's trust says.
|
|
429
|
+
const summaries = [];
|
|
430
|
+
const orgPlugins = [];
|
|
431
|
+
// Only keys that will actually load shadow an org copy: an untrusted repo's
|
|
432
|
+
// plugins never load, so they must not hide the org marketplace's copy.
|
|
433
|
+
const seenKeys = new Set([...plugins.user, ...(trusted === true ? plugins.project : [])].map((p) => p.key));
|
|
434
|
+
for (const mp of deps.marketplaces ?? []) {
|
|
435
|
+
const summary = emptySummary(mp.label, "org-marketplace");
|
|
436
|
+
const found = marketplacePlugins(mp);
|
|
437
|
+
const fresh = found.plugins.filter((p) => !seenKeys.has(p.key));
|
|
438
|
+
for (const p of fresh)
|
|
439
|
+
seenKeys.add(p.key);
|
|
440
|
+
orgPlugins.push(...fresh);
|
|
441
|
+
addPluginsToSummary(summary, fresh);
|
|
442
|
+
summary.unsupported.push(...found.unsupported);
|
|
443
|
+
summaries.push(summary);
|
|
444
|
+
}
|
|
445
|
+
const extraArgv = [];
|
|
446
|
+
const extraRulesDirs = [];
|
|
447
|
+
for (const extra of deps.extraRoots ?? []) {
|
|
448
|
+
const summary = emptySummary(extra.label, "personal");
|
|
449
|
+
const found = (rels) => rels.map((rel) => safeSourceDir(extra.root, rel)).filter((dir) => dir !== null);
|
|
450
|
+
summary.skills.push(...found(EXTRA_ROOT_LAYOUT.skills));
|
|
451
|
+
summary.commands.push(...found(EXTRA_ROOT_LAYOUT.commands));
|
|
452
|
+
summary.rulesDirs.push(...found(EXTRA_ROOT_LAYOUT.rules));
|
|
453
|
+
for (const dir of summary.skills)
|
|
454
|
+
extraArgv.push("--skill", dir);
|
|
455
|
+
for (const dir of summary.commands)
|
|
456
|
+
extraArgv.push("--prompt-template", dir);
|
|
457
|
+
extraRulesDirs.push(...summary.rulesDirs);
|
|
458
|
+
summaries.push(summary);
|
|
459
|
+
}
|
|
460
|
+
const nativeSkills = [];
|
|
461
|
+
const nativePrompts = [];
|
|
462
|
+
if (trusted === true && deps.projectNativeSkills === true) {
|
|
463
|
+
for (const rel of PROJECT_NATIVE_SKILLS) {
|
|
464
|
+
const dir = safeSourceDir(deps.cwd, rel);
|
|
465
|
+
if (dir)
|
|
466
|
+
nativeSkills.push(dir);
|
|
467
|
+
}
|
|
468
|
+
for (const rel of PROJECT_NATIVE_PROMPTS) {
|
|
469
|
+
const dir = safeSourceDir(deps.cwd, rel);
|
|
470
|
+
if (dir)
|
|
471
|
+
nativePrompts.push(dir);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const argv = [...decision.userArgv, ...pluginsToArgv(orgPlugins), ...extraArgv];
|
|
475
|
+
if (trusted === true) {
|
|
291
476
|
argv.push(...decision.projectArgv);
|
|
477
|
+
for (const dir of nativeSkills)
|
|
478
|
+
argv.push("--skill", dir);
|
|
479
|
+
for (const dir of nativePrompts)
|
|
480
|
+
argv.push("--prompt-template", dir);
|
|
481
|
+
}
|
|
292
482
|
const agentDirs = [
|
|
293
483
|
...decision.userAgentDirs,
|
|
484
|
+
...orgPlugins.flatMap((p) => p.agentDirs),
|
|
294
485
|
...(trusted === true ? decision.projectAgentDirs : []),
|
|
295
486
|
];
|
|
296
487
|
// User rules first, project rules last: the extension appends in order, so
|
|
297
488
|
// project rules end up closest to the task — matching Claude Code priority.
|
|
298
489
|
const rulesDirs = [
|
|
299
490
|
...decision.userRulesDirs,
|
|
491
|
+
...extraRulesDirs,
|
|
300
492
|
...(trusted === true ? decision.projectRulesDirs : []),
|
|
301
493
|
];
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
494
|
+
// Org plugin servers come from repo config, so they ride the approval gate.
|
|
495
|
+
const mcpEntries = deps.pluginMcp === false
|
|
496
|
+
? []
|
|
497
|
+
: [
|
|
498
|
+
...decision.userMcp,
|
|
499
|
+
...pluginMcpEntries(orgPlugins, true),
|
|
500
|
+
...(trusted === true ? decision.projectMcp : []),
|
|
501
|
+
];
|
|
306
502
|
const loadedKeys = [
|
|
307
503
|
...plugins.user.map((p) => p.key),
|
|
504
|
+
...orgPlugins.map((p) => p.key),
|
|
308
505
|
...(trusted === true ? plugins.project.map((p) => p.key) : []),
|
|
309
506
|
];
|
|
507
|
+
const user = emptySummary("~/.claude", "user");
|
|
508
|
+
if (userDirs.skills)
|
|
509
|
+
user.skills.push(userDirs.skills);
|
|
510
|
+
if (userDirs.commands)
|
|
511
|
+
user.commands.push(userDirs.commands);
|
|
512
|
+
user.rulesDirs.push(...decision.userRulesDirs);
|
|
513
|
+
addPluginsToSummary(user, plugins.user);
|
|
514
|
+
summaries.unshift(user);
|
|
515
|
+
if (trusted === true) {
|
|
516
|
+
const project = emptySummary("repository", "project");
|
|
517
|
+
if (projectDirs.skills)
|
|
518
|
+
project.skills.push(projectDirs.skills);
|
|
519
|
+
if (projectDirs.commands)
|
|
520
|
+
project.commands.push(projectDirs.commands);
|
|
521
|
+
project.rulesDirs.push(...decision.projectRulesDirs);
|
|
522
|
+
project.skills.push(...nativeSkills);
|
|
523
|
+
project.commands.push(...nativePrompts);
|
|
524
|
+
addPluginsToSummary(project, plugins.project);
|
|
525
|
+
summaries.push(project);
|
|
526
|
+
}
|
|
310
527
|
const extraEnv = {};
|
|
311
528
|
if (agentDirs.length > 0)
|
|
312
529
|
extraEnv[CLAUDE_AGENT_DIRS_ENV] = agentDirs.join(delimiter);
|
|
@@ -316,7 +533,10 @@ export async function claudeCompatArgs(deps) {
|
|
|
316
533
|
extraEnv[CLAUDE_PLUGIN_MCP_ENV] = JSON.stringify(mcpEntries);
|
|
317
534
|
if (loadedKeys.length > 0)
|
|
318
535
|
extraEnv[CLAUDE_PLUGIN_KEYS_ENV] = loadedKeys.join(delimiter);
|
|
319
|
-
|
|
536
|
+
// Explicitly requested sources are always reported (an empty one is news);
|
|
537
|
+
// user and project scope only when they contributed something.
|
|
538
|
+
const sources = summaries.filter((s) => s.kind === "org-marketplace" || s.kind === "personal" || summaryHasContent(s));
|
|
539
|
+
return { argv, env: extraEnv, ...(sources.length > 0 ? { sources } : {}) };
|
|
320
540
|
}
|
|
321
541
|
function toInventory(plugin, gated) {
|
|
322
542
|
return {
|
package/dist/claudePlugins.d.ts
CHANGED
|
@@ -38,7 +38,12 @@
|
|
|
38
38
|
* cannot reach outside its repo/plugin.
|
|
39
39
|
*/
|
|
40
40
|
import type { PluginLedger } from "./pluginStore.js";
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* `org-marketplace`: a workspace's plugin marketplace snapshot handed to a
|
|
43
|
+
* cloud run (YAG-784); loaded by `claudeCompatArgs`'s `marketplaces` option,
|
|
44
|
+
* never by `discoverClaudePlugins`.
|
|
45
|
+
*/
|
|
46
|
+
export type PluginOrigin = "yagni" | "claude-code" | "repo-marketplace" | "org-marketplace";
|
|
42
47
|
export interface PluginMcp {
|
|
43
48
|
/** File the servers came from (plugin `.mcp.json` or `plugin.json`). */
|
|
44
49
|
sourcePath: string;
|
|
@@ -98,6 +103,8 @@ export interface EnableState {
|
|
|
98
103
|
* project `.claude/settings.local.json`.
|
|
99
104
|
*/
|
|
100
105
|
export declare function readEnabledPlugins(cwd: string, homeDir: string): Map<string, EnableState>;
|
|
106
|
+
/** Project-scope `enabledPlugins` only (no user `~/.claude/settings.json`). */
|
|
107
|
+
export declare function readProjectEnabledPlugins(cwd: string): Map<string, EnableState>;
|
|
101
108
|
/** `~/.claude/plugins/known_marketplaces.json` → marketplace name → checkout dir. */
|
|
102
109
|
export declare function readKnownMarketplaces(homeDir: string): Map<string, string>;
|
|
103
110
|
/**
|
|
@@ -142,6 +149,13 @@ export interface DiscoverPluginsDeps {
|
|
|
142
149
|
homeDir: string;
|
|
143
150
|
/** YAGNI's own ledger (`readLedger` from pluginStore.ts); wins over the mirrored Claude Code state. */
|
|
144
151
|
yagniLedger?: PluginLedger;
|
|
152
|
+
/**
|
|
153
|
+
* False skips everything under `homeDir` (the YAGNI ledger, Claude Code's
|
|
154
|
+
* install ledger, known marketplaces, user `enabledPlugins`), leaving only
|
|
155
|
+
* the repo's own marketplace and project `enabledPlugins` opt-outs. Headless
|
|
156
|
+
* cloud runs use it so nothing a sandbox step wrote under HOME can load.
|
|
157
|
+
*/
|
|
158
|
+
includeUserScope?: boolean;
|
|
145
159
|
}
|
|
146
160
|
/**
|
|
147
161
|
* All locally-present Claude Code plugin content relevant to `cwd`, split by
|
package/dist/claudePlugins.js
CHANGED
|
@@ -145,6 +145,13 @@ export function readEnabledPlugins(cwd, homeDir) {
|
|
|
145
145
|
collectEnabled(join(cwd, ".claude", "settings.local.json"), "project", out);
|
|
146
146
|
return out;
|
|
147
147
|
}
|
|
148
|
+
/** Project-scope `enabledPlugins` only (no user `~/.claude/settings.json`). */
|
|
149
|
+
export function readProjectEnabledPlugins(cwd) {
|
|
150
|
+
const out = new Map();
|
|
151
|
+
collectEnabled(join(cwd, ".claude", "settings.json"), "project", out);
|
|
152
|
+
collectEnabled(join(cwd, ".claude", "settings.local.json"), "project", out);
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
148
155
|
/** `~/.claude/plugins/known_marketplaces.json` → marketplace name → checkout dir. */
|
|
149
156
|
export function readKnownMarketplaces(homeDir) {
|
|
150
157
|
const out = new Map();
|
|
@@ -365,9 +372,10 @@ function resolveInstalledRoot(key, installed, marketplaces, realCwd) {
|
|
|
365
372
|
*/
|
|
366
373
|
export function discoverClaudePlugins(deps) {
|
|
367
374
|
try {
|
|
368
|
-
const
|
|
369
|
-
const
|
|
370
|
-
const
|
|
375
|
+
const userScope = deps.includeUserScope !== false;
|
|
376
|
+
const installed = userScope ? readInstalledPlugins(deps.homeDir) : new Map();
|
|
377
|
+
const enabled = userScope ? readEnabledPlugins(deps.cwd, deps.homeDir) : readProjectEnabledPlugins(deps.cwd);
|
|
378
|
+
const marketplaces = userScope ? readKnownMarketplaces(deps.homeDir) : new Map();
|
|
371
379
|
const realCwd = realOrNull(deps.cwd) ?? resolve(deps.cwd);
|
|
372
380
|
const user = [];
|
|
373
381
|
const project = [];
|
|
@@ -375,7 +383,7 @@ export function discoverClaudePlugins(deps) {
|
|
|
375
383
|
// YAGNI-installed plugins are the user's own explicit action (`yagni
|
|
376
384
|
// plugin install`), so both scopes load without ceremony; a project-scope
|
|
377
385
|
// install applies only inside its repo.
|
|
378
|
-
for (const [key, entries] of Object.entries(deps.yagniLedger?.plugins ?? {})) {
|
|
386
|
+
for (const [key, entries] of Object.entries((userScope ? deps.yagniLedger?.plugins : undefined) ?? {})) {
|
|
379
387
|
const applicable = entries.filter((e) => {
|
|
380
388
|
if (!e.enabled || !isDirectory(e.installPath))
|
|
381
389
|
return false;
|
|
@@ -143,6 +143,11 @@ export interface HeadlessGoDeps {
|
|
|
143
143
|
env?: NodeJS.ProcessEnv;
|
|
144
144
|
/** Environment for spawned stage children. Defaults to `env`. */
|
|
145
145
|
childEnv?: NodeJS.ProcessEnv;
|
|
146
|
+
/**
|
|
147
|
+
* Extra pi flags for the build and fix stage children: the launcher's
|
|
148
|
+
* `--skill` / `--prompt-template` paths (YAG-784). See RunPipelineDeps.extraArgs.
|
|
149
|
+
*/
|
|
150
|
+
extraArgs?: string[];
|
|
146
151
|
/** Wall clock (epoch ms) the host's time left is anchored to. Defaults to `Date.now`; injected in tests. */
|
|
147
152
|
now?: () => number;
|
|
148
153
|
runPipeline?: typeof defaultRunPipeline;
|
|
@@ -322,6 +322,7 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
322
322
|
const pipelineDeps = {
|
|
323
323
|
cwd,
|
|
324
324
|
childEnv,
|
|
325
|
+
...(deps.extraArgs?.length ? { extraArgs: deps.extraArgs } : {}),
|
|
325
326
|
// `go.fanout` rides the same env surface as the tier cap, so a benchmark lane
|
|
326
327
|
// pins the implement diamond without a second flag (spec decision 8).
|
|
327
328
|
fanout: resolveFanoutMode(env),
|
|
@@ -73,5 +73,6 @@ export declare function buildStageInvocation(stage: PipelineStage, ctx: {
|
|
|
73
73
|
export declare function groundedChildArgv(stageArgv: string[], opts: {
|
|
74
74
|
piCli: string;
|
|
75
75
|
extensionPath: string;
|
|
76
|
+
extraArgs?: string[];
|
|
76
77
|
}): string[];
|
|
77
78
|
//# sourceMappingURL=invocation.d.ts.map
|
|
@@ -89,12 +89,19 @@ export function buildStageInvocation(stage, ctx) {
|
|
|
89
89
|
*/
|
|
90
90
|
export function groundedChildArgv(stageArgv, opts) {
|
|
91
91
|
const userChoseProvider = stageArgv.includes("--provider");
|
|
92
|
+
// Extra flags (the launcher's `--skill` / `--prompt-template` paths for a
|
|
93
|
+
// build or fix stage) go before the positional `Task: …` prompt, which pi
|
|
94
|
+
// reads as the message only while it stays last.
|
|
95
|
+
const extra = opts.extraArgs ?? [];
|
|
96
|
+
const passthrough = extra.length > 0 && stageArgv.length > 0
|
|
97
|
+
? [...stageArgv.slice(0, -1), ...extra, stageArgv[stageArgv.length - 1]]
|
|
98
|
+
: stageArgv;
|
|
92
99
|
return [
|
|
93
100
|
opts.piCli,
|
|
94
101
|
"-e",
|
|
95
102
|
opts.extensionPath,
|
|
96
103
|
...(userChoseProvider ? [] : ["--provider", "yagni"]),
|
|
97
|
-
...
|
|
104
|
+
...passthrough,
|
|
98
105
|
];
|
|
99
106
|
}
|
|
100
107
|
//# sourceMappingURL=invocation.js.map
|
|
@@ -98,6 +98,14 @@ export interface RunPipelineDeps {
|
|
|
98
98
|
grounded?: boolean;
|
|
99
99
|
/** Environment inherited by spawned child stages. Defaults to the parent process env. */
|
|
100
100
|
childEnv?: NodeJS.ProcessEnv;
|
|
101
|
+
/**
|
|
102
|
+
* Extra pi flags for the stages that write code: the single-writer
|
|
103
|
+
* implement, each builder, the synthesizer, every fixer, and the review
|
|
104
|
+
* loop's fix stage. Headless /go passes the launcher's `--skill` /
|
|
105
|
+
* `--prompt-template` paths here (YAG-784). Map, plan, the partitioner and
|
|
106
|
+
* the review lenses never get them.
|
|
107
|
+
*/
|
|
108
|
+
extraArgs?: string[];
|
|
101
109
|
/**
|
|
102
110
|
* The no-op guard's git seam (injected for unit tests). Captures the repo
|
|
103
111
|
* HEAD + porcelain status so the orchestrator can tell whether the build half
|
|
@@ -275,12 +275,14 @@ export async function runPipeline(ticket, deps) {
|
|
|
275
275
|
// `go:implement:<workstream>`), so per-workstream spend attributes for free the
|
|
276
276
|
// way review lenses do. Every other stage leaves it undefined and keeps the
|
|
277
277
|
// runner's own `go:<stage>` default.
|
|
278
|
-
|
|
278
|
+
// `writes` marks a build or fix child, the only ones that get `extraArgs`.
|
|
279
|
+
const stageDepsFor = (tag, callerLabel, writes = false) => {
|
|
279
280
|
const onEvent = deps.onEvent;
|
|
280
281
|
return {
|
|
281
282
|
cwd: deps.cwd,
|
|
282
283
|
signal: deps.signal,
|
|
283
284
|
...(deps.childEnv ? { env: deps.childEnv } : {}),
|
|
285
|
+
...(writes && deps.extraArgs?.length ? { extraArgs: deps.extraArgs } : {}),
|
|
284
286
|
...(runId ? { attribution: { runId } } : {}),
|
|
285
287
|
...(callerLabel ? { callerLabel } : {}),
|
|
286
288
|
onEvent: (ev) => {
|
|
@@ -462,7 +464,7 @@ export async function runPipeline(ticket, deps) {
|
|
|
462
464
|
// after the builders have fixed what it pointed at.
|
|
463
465
|
let summaryDefects = [];
|
|
464
466
|
if (fanned) {
|
|
465
|
-
const synth = await runStageFn({ ...synthesizerStage(), tools: stage.tools }, { ticket: promptTicket, previous: composeSynthesizerInput(handoff, report), grounded }, stageDepsFor({ stageId: stage.id }, SYNTHESIZER_CALLER_LABEL));
|
|
467
|
+
const synth = await runStageFn({ ...synthesizerStage(), tools: stage.tools }, { ticket: promptTicket, previous: composeSynthesizerInput(handoff, report), grounded }, stageDepsFor({ stageId: stage.id }, SYNTHESIZER_CALLER_LABEL, true));
|
|
466
468
|
stages.push(synth);
|
|
467
469
|
if (!isFailed(synth) && synth.finalOutput.trim()) {
|
|
468
470
|
body = synth.finalOutput;
|
|
@@ -495,7 +497,7 @@ export async function runPipeline(ticket, deps) {
|
|
|
495
497
|
const fixes = await mapWithConcurrencyLimit(assigned, ceiling, async (a) => {
|
|
496
498
|
const siblings = workstreams.filter((s) => s.name !== a.workstream.name);
|
|
497
499
|
progress({ kind: "stage_start", stageId: stage.id, workstream: a.workstream.name });
|
|
498
|
-
const fixed = await runStageFn({ ...fixerStage(a.workstream, renderFindings(a.findings), siblings), tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id, workstream: a.workstream.name }, workstreamCallerLabel(a.workstream.name)));
|
|
500
|
+
const fixed = await runStageFn({ ...fixerStage(a.workstream, renderFindings(a.findings), siblings), tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id, workstream: a.workstream.name }, workstreamCallerLabel(a.workstream.name), true));
|
|
499
501
|
progress({
|
|
500
502
|
kind: "stage_done",
|
|
501
503
|
stageId: stage.id,
|
|
@@ -520,7 +522,7 @@ export async function runPipeline(ticket, deps) {
|
|
|
520
522
|
if (unassigned.length > 0) {
|
|
521
523
|
const findingsText = renderFindings(unassigned);
|
|
522
524
|
const fixStage = fanned ? synthesizerFixStage(findingsText) : fixerStage(undefined, findingsText);
|
|
523
|
-
const fixed = await runStageFn({ ...fixStage, tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id }, fanned ? SYNTHESIZER_CALLER_LABEL : undefined));
|
|
525
|
+
const fixed = await runStageFn({ ...fixStage, tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id }, fanned ? SYNTHESIZER_CALLER_LABEL : undefined, true));
|
|
524
526
|
stages.push(fixed);
|
|
525
527
|
if (fanned) {
|
|
526
528
|
if (!isFailed(fixed) && fixed.finalOutput.trim()) {
|
|
@@ -587,7 +589,7 @@ export async function runPipeline(ticket, deps) {
|
|
|
587
589
|
const partitionTag = { stageId: stage.id };
|
|
588
590
|
const runSingleWriter = async (decision) => {
|
|
589
591
|
recordDecision(decision, beforeBuild);
|
|
590
|
-
const written = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
|
|
592
|
+
const written = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }, undefined, true));
|
|
591
593
|
stages.push(written);
|
|
592
594
|
// A failed or credit-starved writer never reaches the checker: the build
|
|
593
595
|
// loop's own guard turns it into `partial` or the honest stage error, and
|
|
@@ -670,7 +672,7 @@ export async function runPipeline(ticket, deps) {
|
|
|
670
672
|
// starts and finishes on its own timing, tagged so interleaved events stay
|
|
671
673
|
// attributed, and callers that ignore the tag still see one implement pair.
|
|
672
674
|
progress({ kind: "stage_start", stageId: stage.id, workstream: w.name });
|
|
673
|
-
const built = await runStageFn({ ...builderStage(w, siblings), tools: stage.tools }, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id, workstream: w.name }, workstreamCallerLabel(w.name)));
|
|
675
|
+
const built = await runStageFn({ ...builderStage(w, siblings), tools: stage.tools }, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id, workstream: w.name }, workstreamCallerLabel(w.name), true));
|
|
674
676
|
// A child that died says so on its own done beat (the `degraded` flag the
|
|
675
677
|
// review lenses already use), so no surface paints a dead builder as built.
|
|
676
678
|
progress({
|
|
@@ -1107,7 +1109,7 @@ export async function runPipeline(ticket, deps) {
|
|
|
1107
1109
|
const fixStartedAt = clock();
|
|
1108
1110
|
progress({ kind: "stage_start", stageId: "fix", round });
|
|
1109
1111
|
stageEvent("fix", "start", { round });
|
|
1110
|
-
const fixResult = await runStageFn(fix, { ticket: promptTicket, previous: designContext ? `${serializeBlocking(findings)}\n\n${designContext}` : serializeBlocking(findings), grounded }, stageDepsFor({ stageId: "fix", round }));
|
|
1112
|
+
const fixResult = await runStageFn(fix, { ticket: promptTicket, previous: designContext ? `${serializeBlocking(findings)}\n\n${designContext}` : serializeBlocking(findings), grounded }, stageDepsFor({ stageId: "fix", round }, undefined, true));
|
|
1111
1113
|
observedMs.fix = clock() - fixStartedAt;
|
|
1112
1114
|
stages.push(fixResult);
|
|
1113
1115
|
log("stage_done", { stageId: "fix", exitCode: fixResult.exitCode });
|
|
@@ -35,6 +35,12 @@ export interface RunStageDeps {
|
|
|
35
35
|
};
|
|
36
36
|
/** Optional environment for spawned child stages. Defaults to Node's inherited env. */
|
|
37
37
|
env?: NodeJS.ProcessEnv;
|
|
38
|
+
/**
|
|
39
|
+
* Extra pi flags for this child, placed before the positional task prompt.
|
|
40
|
+
* Headless /go passes the launcher's `--skill` / `--prompt-template` paths
|
|
41
|
+
* here for build and fix stages only (YAG-784).
|
|
42
|
+
*/
|
|
43
|
+
extraArgs?: string[];
|
|
38
44
|
/**
|
|
39
45
|
* YAG-471 attribution: the tracked run's server-assigned id (a UUID), sent to
|
|
40
46
|
* the child as `YAGNI_RUN_ID` so the model proxy's `x-yagni-run-id` header
|
|
@@ -122,7 +122,11 @@ export async function runStage(stage, ctx, deps) {
|
|
|
122
122
|
...(tierCap ? { tierCap } : {}),
|
|
123
123
|
});
|
|
124
124
|
const { piCli, extensionPath } = resolveChild();
|
|
125
|
-
const argv = groundedChildArgv(passthrough, {
|
|
125
|
+
const argv = groundedChildArgv(passthrough, {
|
|
126
|
+
piCli,
|
|
127
|
+
extensionPath,
|
|
128
|
+
...(deps.extraArgs?.length ? { extraArgs: deps.extraArgs } : {}),
|
|
129
|
+
});
|
|
126
130
|
// Fold events into a fixed-size accumulator as they stream — never retain
|
|
127
131
|
// the full array (that OOMs on a verbose stage; YAG-317 follow-up).
|
|
128
132
|
const acc = newEventAccumulator();
|
package/dist/goHeadless.d.ts
CHANGED
|
@@ -14,7 +14,18 @@
|
|
|
14
14
|
* `--plan-file`, optional `--memo-file`, `--run-id`, `--json`), so what Jack
|
|
15
15
|
* runs locally and what the fleet executes are the same binary and the same
|
|
16
16
|
* pipeline.
|
|
17
|
+
*
|
|
18
|
+
* Skills (YAG-784): the launcher also wires the same Claude Code compat seams
|
|
19
|
+
* an interactive launch gets, minus plugin MCP. In a cloud run the backend's
|
|
20
|
+
* `sources.json` mission file decides: the repo's committed skills load only
|
|
21
|
+
* when an admin marked it trusted for agent runs, and the org marketplace and
|
|
22
|
+
* personal skills snapshots load from AGENT_SOURCES_ROOT. With no such file
|
|
23
|
+
* (every local run) the folder's own pi trust decides, as it does
|
|
24
|
+
* interactively. The skill flags reach only the build and fix stages; rules
|
|
25
|
+
* and agent dirs ride the env to every stage.
|
|
17
26
|
*/
|
|
27
|
+
import { type AgentRunSources } from "./agentSources.js";
|
|
28
|
+
import { type ClaudeCompatDeps, type ClaudeCompatLaunch, type CompatSourceSummary } from "./claudeCompat.js";
|
|
18
29
|
import { type OtelLaunchConfig } from "./otel.js";
|
|
19
30
|
/** Mirrors HEADLESS_GO_EXIT in the extension; duplicated to keep the packages independent. */
|
|
20
31
|
export declare const GO_EXIT: {
|
|
@@ -28,6 +39,7 @@ interface HeadlessGoModule {
|
|
|
28
39
|
cwd?: string;
|
|
29
40
|
env?: NodeJS.ProcessEnv;
|
|
30
41
|
childEnv?: NodeJS.ProcessEnv;
|
|
42
|
+
extraArgs?: string[];
|
|
31
43
|
}) => Promise<{
|
|
32
44
|
exitCode: number;
|
|
33
45
|
}>;
|
|
@@ -48,6 +60,12 @@ export interface GoCommandDeps {
|
|
|
48
60
|
writeErr?: (line: string) => void;
|
|
49
61
|
env?: NodeJS.ProcessEnv;
|
|
50
62
|
cwd?: string;
|
|
63
|
+
/** Compat seam (tests inject one so nothing reads the real home). */
|
|
64
|
+
claudeCompat?: (deps: ClaudeCompatDeps) => Promise<ClaudeCompatLaunch>;
|
|
65
|
+
/** The `sources.json` reader; defaults to `$YAGNI_MISSION_DIR/sources.json`. */
|
|
66
|
+
readSources?: (env: NodeJS.ProcessEnv) => AgentRunSources | null;
|
|
67
|
+
/** Root every cloud snapshot dir must sit under; tests point it at a temp dir. */
|
|
68
|
+
sourcesRoot?: string;
|
|
51
69
|
}
|
|
52
70
|
/**
|
|
53
71
|
* Build the environment every pipeline stage child inherits: the proxy
|
|
@@ -68,6 +86,12 @@ export declare function buildHeadlessChildEnv(opts: {
|
|
|
68
86
|
/** Resolved OTel export config; stage children export when present. */
|
|
69
87
|
otel?: OtelLaunchConfig;
|
|
70
88
|
}): NodeJS.ProcessEnv;
|
|
89
|
+
/** The one stderr line naming what each source contributed; null when there is nothing to say. */
|
|
90
|
+
export declare function skillsSummaryLine(opts: {
|
|
91
|
+
cloud: AgentRunSources | null;
|
|
92
|
+
sources: CompatSourceSummary[];
|
|
93
|
+
refused: string[];
|
|
94
|
+
}): string | null;
|
|
71
95
|
/**
|
|
72
96
|
* Run `yagni go`. Returns the process exit code: 0 only on a verified
|
|
73
97
|
* candidate, 1 on any other pipeline outcome, 2 on a usage or credential
|
package/dist/goHeadless.js
CHANGED
|
@@ -14,11 +14,23 @@
|
|
|
14
14
|
* `--plan-file`, optional `--memo-file`, `--run-id`, `--json`), so what Jack
|
|
15
15
|
* runs locally and what the fleet executes are the same binary and the same
|
|
16
16
|
* pipeline.
|
|
17
|
+
*
|
|
18
|
+
* Skills (YAG-784): the launcher also wires the same Claude Code compat seams
|
|
19
|
+
* an interactive launch gets, minus plugin MCP. In a cloud run the backend's
|
|
20
|
+
* `sources.json` mission file decides: the repo's committed skills load only
|
|
21
|
+
* when an admin marked it trusted for agent runs, and the org marketplace and
|
|
22
|
+
* personal skills snapshots load from AGENT_SOURCES_ROOT. With no such file
|
|
23
|
+
* (every local run) the folder's own pi trust decides, as it does
|
|
24
|
+
* interactively. The skill flags reach only the build and fix stages; rules
|
|
25
|
+
* and agent dirs ride the env to every stage.
|
|
17
26
|
*/
|
|
18
27
|
import { randomUUID } from "node:crypto";
|
|
19
28
|
import { mkdirSync } from "node:fs";
|
|
29
|
+
import { resolve } from "node:path";
|
|
20
30
|
import { pathToFileURL } from "node:url";
|
|
31
|
+
import { AGENT_SOURCES_ROOT, cloudCompatOptions, missionDirFromEnv, readAgentRunSources, } from "./agentSources.js";
|
|
21
32
|
import { agentDirEnvVar } from "./branding.js";
|
|
33
|
+
import { CLAUDE_AGENT_DIRS_ENV, CLAUDE_PLUGIN_KEYS_ENV, CLAUDE_PLUGIN_MCP_ENV, CLAUDE_RULES_DIRS_ENV, claudeCompatArgs, } from "./claudeCompat.js";
|
|
22
34
|
import { agentDir, credentialsDir } from "./credentials.js";
|
|
23
35
|
import { otelChildEnv, resolveOtelLaunchWithWorkspace } from "./otel.js";
|
|
24
36
|
import { credentialsFromProfile, profilePath, readActiveProfile } from "./profiles.js";
|
|
@@ -60,6 +72,83 @@ export function buildHeadlessChildEnv(opts) {
|
|
|
60
72
|
...(opts.otel ? otelChildEnv(opts.otel, opts.baseEnv) : {}),
|
|
61
73
|
};
|
|
62
74
|
}
|
|
75
|
+
/** The compat keys a headless launch computes itself rather than inherits. */
|
|
76
|
+
const COMPAT_ENV_KEYS = [CLAUDE_AGENT_DIRS_ENV, CLAUDE_RULES_DIRS_ENV, CLAUDE_PLUGIN_MCP_ENV, CLAUDE_PLUGIN_KEYS_ENV];
|
|
77
|
+
/** The run's working dir as the pipeline will resolve it (`--cwd` wins). */
|
|
78
|
+
function pipelineCwd(args, fallback) {
|
|
79
|
+
for (let i = 0; i < args.length; i++) {
|
|
80
|
+
const token = args[i];
|
|
81
|
+
if (token === "--cwd" && args[i + 1])
|
|
82
|
+
return resolve(fallback, args[i + 1]);
|
|
83
|
+
if (token.startsWith("--cwd=") && token.length > 6)
|
|
84
|
+
return resolve(fallback, token.slice(6));
|
|
85
|
+
}
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
function countsOf(s) {
|
|
89
|
+
const parts = [];
|
|
90
|
+
const n = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
91
|
+
if (s.plugins.length > 0)
|
|
92
|
+
parts.push(n(s.plugins.length, "plugin"));
|
|
93
|
+
if (s.skills.length > 0)
|
|
94
|
+
parts.push(n(s.skills.length, "skills dir"));
|
|
95
|
+
if (s.commands.length > 0)
|
|
96
|
+
parts.push(n(s.commands.length, "commands dir"));
|
|
97
|
+
if (s.rulesDirs.length > 0)
|
|
98
|
+
parts.push(n(s.rulesDirs.length, "rules dir"));
|
|
99
|
+
if (s.unsupported.length > 0)
|
|
100
|
+
parts.push(`${s.unsupported.length} not loaded (${s.unsupported.join("; ")})`);
|
|
101
|
+
return parts.length > 0 ? parts.join(", ") : "nothing to load";
|
|
102
|
+
}
|
|
103
|
+
/** The one stderr line naming what each source contributed; null when there is nothing to say. */
|
|
104
|
+
export function skillsSummaryLine(opts) {
|
|
105
|
+
const parts = opts.sources.map((s) => {
|
|
106
|
+
const label = s.kind === "project" ? `${opts.cloud?.repo.fullName ?? "repository"} (trusted)` : s.label;
|
|
107
|
+
return `${label}: ${countsOf(s)}`;
|
|
108
|
+
});
|
|
109
|
+
if (opts.cloud && !opts.cloud.repo.trusted)
|
|
110
|
+
parts.push(`${opts.cloud.repo.fullName}: repository skills off (not trusted for agent runs)`);
|
|
111
|
+
for (const refused of opts.refused)
|
|
112
|
+
parts.push(`${refused} (skipped)`);
|
|
113
|
+
return parts.length > 0 ? `Skills: ${parts.join(" | ")}` : null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The compat argv + env for a headless run. Cloud mode (a valid sources.json)
|
|
117
|
+
* trusts what the backend decided and never reads HOME; local mode keeps the
|
|
118
|
+
* folder's pi trust decision and the user's own `~/.claude`. Plugin MCP never
|
|
119
|
+
* loads headlessly. Never throws: a failure here only means no skills, and
|
|
120
|
+
* it says so on stderr so it is not mistaken for "nothing configured".
|
|
121
|
+
*/
|
|
122
|
+
async function headlessCompat(opts) {
|
|
123
|
+
const { cwd, agentDir, childEnv, deps, writeErr } = opts;
|
|
124
|
+
try {
|
|
125
|
+
const cloud = (deps.readSources ?? ((env) => readAgentRunSources(missionDirFromEnv(env), writeErr)))(childEnv);
|
|
126
|
+
const compat = deps.claudeCompat ?? claudeCompatArgs;
|
|
127
|
+
const base = { cwd, agentDir, env: childEnv, interactive: false, pluginMcp: false };
|
|
128
|
+
let refused = [];
|
|
129
|
+
let launch;
|
|
130
|
+
if (cloud) {
|
|
131
|
+
const plan = cloudCompatOptions(cloud, deps.sourcesRoot ?? AGENT_SOURCES_ROOT);
|
|
132
|
+
refused = plan.refused;
|
|
133
|
+
launch = await compat({
|
|
134
|
+
...base,
|
|
135
|
+
trustOverride: plan.trustOverride,
|
|
136
|
+
userScope: false,
|
|
137
|
+
projectNativeSkills: true,
|
|
138
|
+
extraRoots: plan.extraRoots,
|
|
139
|
+
marketplaces: plan.marketplaces,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
launch = await compat(base);
|
|
144
|
+
}
|
|
145
|
+
return { launch, summary: skillsSummaryLine({ cloud, sources: launch.sources ?? [], refused }) };
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const why = err instanceof Error && err.message ? err.message : String(err);
|
|
149
|
+
return { launch: { argv: [], env: {} }, summary: `Skills: not loaded (the skills setup failed: ${why})` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
63
152
|
/**
|
|
64
153
|
* Run `yagni go`. Returns the process exit code: 0 only on a verified
|
|
65
154
|
* candidate, 1 on any other pipeline outcome, 2 on a usage or credential
|
|
@@ -135,10 +224,27 @@ export async function goCommand(args, deps = {}, cliVersion) {
|
|
|
135
224
|
writeErr(err instanceof Error ? err.message : String(err));
|
|
136
225
|
return GO_EXIT.usage;
|
|
137
226
|
}
|
|
227
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
228
|
+
const compat = await headlessCompat({
|
|
229
|
+
cwd: pipelineCwd(args, cwd),
|
|
230
|
+
agentDir: childEnv[agentDirEnvVar()],
|
|
231
|
+
childEnv,
|
|
232
|
+
deps,
|
|
233
|
+
writeErr,
|
|
234
|
+
});
|
|
235
|
+
// Recompute the compat keys rather than inherit a parent session's: plugin
|
|
236
|
+
// MCP especially must never reach a headless stage child.
|
|
237
|
+
for (const key of COMPAT_ENV_KEYS)
|
|
238
|
+
delete childEnv[key];
|
|
239
|
+
Object.assign(childEnv, compat.launch.env);
|
|
240
|
+
delete childEnv[CLAUDE_PLUGIN_MCP_ENV];
|
|
241
|
+
if (compat.summary)
|
|
242
|
+
writeErr(compat.summary);
|
|
138
243
|
const outcome = await headless.runHeadlessGo(args, {
|
|
139
|
-
cwd
|
|
244
|
+
cwd,
|
|
140
245
|
env: baseEnv,
|
|
141
246
|
childEnv,
|
|
247
|
+
...(compat.launch.argv.length > 0 ? { extraArgs: compat.launch.argv } : {}),
|
|
142
248
|
});
|
|
143
249
|
return outcome.exitCode;
|
|
144
250
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.2.4-staging.
|
|
3
|
+
"version": "1.2.4-staging.1809.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "33de16e5a97a30639624793c8e9f9d5120b7aea8"
|
|
62
62
|
}
|