@yagni-app/code 1.0.6 → 1.0.8
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/README.md +91 -46
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -5
- package/dist/claudePlugins.js +129 -21
- package/dist/cli.js +16 -4
- package/dist/doctor.d.ts +21 -8
- package/dist/doctor.js +53 -28
- package/dist/extension/askUserQuestionTool.js +7 -2
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/config.d.ts +6 -0
- package/dist/extension/hooks.d.ts +3 -3
- package/dist/extension/hooks.js +30 -5
- package/dist/extension/index.d.ts +19 -0
- package/dist/extension/index.js +248 -43
- package/dist/extension/mcp/cliConfig.d.ts +1 -1
- package/dist/extension/mcp/cliConfig.js +1 -1
- package/dist/extension/mcp/config.d.ts +24 -2
- package/dist/extension/mcp/config.js +75 -3
- package/dist/extension/mcp/manager.d.ts +3 -1
- package/dist/extension/mcp/manager.js +2 -2
- package/dist/extension/mcp/panel.d.ts +0 -1
- package/dist/extension/mcp/panel.js +13 -3
- package/dist/extension/mcp/startup.js +8 -6
- package/dist/extension/permission/gate.d.ts +101 -2
- package/dist/extension/permission/gate.js +485 -42
- package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
- package/dist/extension/permissionRules/bashFileArgs.js +236 -0
- package/dist/extension/permissionRules/engine.d.ts +50 -0
- package/dist/extension/permissionRules/engine.js +238 -0
- package/dist/extension/permissionRules/loadConfig.d.ts +64 -0
- package/dist/extension/permissionRules/loadConfig.js +105 -0
- package/dist/extension/permissionRules/parser.d.ts +38 -0
- package/dist/extension/permissionRules/parser.js +136 -0
- package/dist/extension/permissionRules/pathRules.d.ts +60 -0
- package/dist/extension/permissionRules/pathRules.js +122 -0
- package/dist/extension/permissionRules/shellRules.d.ts +52 -0
- package/dist/extension/permissionRules/shellRules.js +221 -0
- package/dist/extension/pipeline/invocation.d.ts +3 -6
- package/dist/extension/pipeline/invocation.js +3 -6
- package/dist/extension/pipeline/runner.d.ts +0 -1
- package/dist/extension/pipeline/runner.js +6 -14
- package/dist/extension/plugins/inventory.d.ts +88 -0
- package/dist/extension/plugins/inventory.js +144 -0
- package/dist/extension/plugins/panel.d.ts +45 -0
- package/dist/extension/plugins/panel.js +293 -0
- package/dist/extension/sandbox/bash.d.ts +99 -0
- package/dist/extension/sandbox/bash.js +190 -0
- package/dist/extension/sandbox/config.d.ts +115 -0
- package/dist/extension/sandbox/config.js +388 -0
- package/dist/extension/sandbox/manager.d.ts +108 -0
- package/dist/extension/sandbox/manager.js +243 -0
- package/dist/extension/sandbox/panel.d.ts +111 -0
- package/dist/extension/sandbox/panel.js +342 -0
- package/dist/extension/sandbox/session.d.ts +85 -0
- package/dist/extension/sandbox/session.js +829 -0
- package/dist/extension/settingsFiles.d.ts +50 -0
- package/dist/extension/settingsFiles.js +206 -0
- package/dist/extension/telemetry/attrs.d.ts +96 -0
- package/dist/extension/telemetry/attrs.js +149 -0
- package/dist/extension/telemetry/config.d.ts +103 -0
- package/dist/extension/telemetry/config.js +193 -0
- package/dist/extension/telemetry/index.d.ts +7 -0
- package/dist/extension/telemetry/index.js +7 -0
- package/dist/extension/telemetry/probe.d.ts +29 -0
- package/dist/extension/telemetry/probe.js +122 -0
- package/dist/extension/telemetry/register.d.ts +47 -0
- package/dist/extension/telemetry/register.js +207 -0
- package/dist/extension/telemetry/sdk.d.ts +63 -0
- package/dist/extension/telemetry/sdk.js +207 -0
- package/dist/extension/telemetry/tracker.d.ts +131 -0
- package/dist/extension/telemetry/tracker.js +551 -0
- package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
- package/dist/extension/vendor/ignore.d.ts +86 -0
- package/dist/extension/vendor/ignore.js +788 -0
- package/dist/goHeadless.d.ts +1 -1
- package/dist/goHeadless.js +2 -2
- package/dist/launch.d.ts +4 -3
- package/dist/launch.js +7 -4
- package/dist/mcpCommand.d.ts +10 -1
- package/dist/mcpCommand.js +42 -10
- package/dist/otel.d.ts +67 -90
- package/dist/otel.js +152 -195
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +18 -0
- package/dist/pluginCommand.d.ts +43 -0
- package/dist/pluginCommand.js +499 -0
- package/dist/pluginStore.d.ts +170 -0
- package/dist/pluginStore.js +554 -0
- package/dist/upgrade.js +10 -1
- package/package.json +19 -3
package/dist/claudePlugins.js
CHANGED
|
@@ -17,10 +17,19 @@
|
|
|
17
17
|
* relative-path source loads on the project side, unless explicitly
|
|
18
18
|
* disabled via `enabledPlugins`.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* Three sources, first wins on the same `name@marketplace`:
|
|
21
|
+
*
|
|
22
|
+
* 0. YAGNI's own ledger (`~/.yagni-code/plugins`, written by `yagni plugin`;
|
|
23
|
+
* see pluginStore.ts) — handed in by the caller as `yagniLedger`.
|
|
24
|
+
* 1. Claude Code's ledger + `enabledPlugins` (mirrored read-only).
|
|
25
|
+
* 2. The repo's own `.claude-plugin/marketplace.json`.
|
|
26
|
+
*
|
|
27
|
+
* Plugin MCP servers (`.mcp.json` at the plugin root, or the manifest's
|
|
28
|
+
* `mcpServers`) are collected here and bridged by the launcher into the
|
|
29
|
+
* extension's MCP client (see claudeCompat.ts). Deliberately NOT here:
|
|
30
|
+
* network installation (that is pluginStore.ts), plugin hooks, LSP servers,
|
|
31
|
+
* themes, output styles — those are reported as `unsupported` so `yagni
|
|
32
|
+
* plugin list` can say so. Discovery is read-only.
|
|
24
33
|
*
|
|
25
34
|
* Everything is fail-soft: malformed JSON, missing dirs, or hostile path
|
|
26
35
|
* entries degrade to "that plugin absent" — never a failed launch. Path
|
|
@@ -151,10 +160,20 @@ export function readKnownMarketplaces(homeDir) {
|
|
|
151
160
|
}
|
|
152
161
|
return out;
|
|
153
162
|
}
|
|
163
|
+
// ── marketplace file ────────────────────────────────────────────────────────
|
|
164
|
+
/**
|
|
165
|
+
* Names and versions from marketplace/plugin JSON become path segments under
|
|
166
|
+
* the plugin home (`cache/<mp>/<plugin>/<version>`), so they must be plain
|
|
167
|
+
* single segments: no separators, no `.`/`..`, nothing starting with `-`.
|
|
168
|
+
*/
|
|
169
|
+
export const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
170
|
+
export function isSafeSegment(value) {
|
|
171
|
+
return typeof value === "string" && SAFE_SEGMENT.test(value);
|
|
172
|
+
}
|
|
154
173
|
/** Parse `<root>/.claude-plugin/marketplace.json`; null when absent/unusable. */
|
|
155
174
|
export function readMarketplace(root) {
|
|
156
175
|
const parsed = readJsonObject(join(root, ".claude-plugin", "marketplace.json"));
|
|
157
|
-
if (!parsed ||
|
|
176
|
+
if (!parsed || !isSafeSegment(parsed.name))
|
|
158
177
|
return null;
|
|
159
178
|
if (!Array.isArray(parsed.plugins))
|
|
160
179
|
return null;
|
|
@@ -166,10 +185,16 @@ export function readMarketplace(root) {
|
|
|
166
185
|
for (const item of parsed.plugins) {
|
|
167
186
|
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
168
187
|
continue;
|
|
169
|
-
const
|
|
170
|
-
|
|
188
|
+
const entry = item;
|
|
189
|
+
const name = entry.name;
|
|
190
|
+
if (!isSafeSegment(name))
|
|
171
191
|
continue;
|
|
172
|
-
plugins.push({
|
|
192
|
+
plugins.push({
|
|
193
|
+
name,
|
|
194
|
+
source: entry.source,
|
|
195
|
+
...(isSafeSegment(entry.version) ? { version: entry.version } : {}),
|
|
196
|
+
...(typeof entry.description === "string" ? { description: entry.description } : {}),
|
|
197
|
+
});
|
|
173
198
|
}
|
|
174
199
|
return { name: parsed.name, root, pluginRoot, plugins };
|
|
175
200
|
}
|
|
@@ -212,13 +237,17 @@ function asStringArray(value) {
|
|
|
212
237
|
* discovery reads whole dirs), root `SKILL.md` fallback. Null when the plugin
|
|
213
238
|
* has nothing we can bridge.
|
|
214
239
|
*/
|
|
215
|
-
export function pluginAssets(root, name) {
|
|
240
|
+
export function pluginAssets(root, name, meta = {}) {
|
|
216
241
|
if (!isDirectory(root))
|
|
217
242
|
return null;
|
|
218
|
-
const
|
|
243
|
+
const manifestPath = join(root, ".claude-plugin", "plugin.json");
|
|
244
|
+
const manifest = readJsonObject(manifestPath) ?? {};
|
|
245
|
+
// Default component locations go through the same containment check as
|
|
246
|
+
// manifest-declared ones: a `skills` symlink pointing outside the plugin
|
|
247
|
+
// must not hand that directory to pi.
|
|
219
248
|
const skillPaths = [];
|
|
220
|
-
const defaultSkills =
|
|
221
|
-
if (isDirectory(defaultSkills))
|
|
249
|
+
const defaultSkills = containedExistingPath(root, "skills");
|
|
250
|
+
if (defaultSkills && isDirectory(defaultSkills))
|
|
222
251
|
skillPaths.push(defaultSkills);
|
|
223
252
|
const manifestSkills = asStringArray(manifest.skills);
|
|
224
253
|
for (const rel of manifestSkills) {
|
|
@@ -227,8 +256,8 @@ export function pluginAssets(root, name) {
|
|
|
227
256
|
skillPaths.push(contained);
|
|
228
257
|
}
|
|
229
258
|
if (skillPaths.length === 0 && manifestSkills.length === 0) {
|
|
230
|
-
const rootSkill =
|
|
231
|
-
if (isFile(rootSkill))
|
|
259
|
+
const rootSkill = containedExistingPath(root, "SKILL.md");
|
|
260
|
+
if (rootSkill && isFile(rootSkill))
|
|
232
261
|
skillPaths.push(rootSkill);
|
|
233
262
|
}
|
|
234
263
|
const componentPaths = (manifestValue, defaultDir) => {
|
|
@@ -242,14 +271,62 @@ export function pluginAssets(root, name) {
|
|
|
242
271
|
}
|
|
243
272
|
return out;
|
|
244
273
|
}
|
|
245
|
-
const def =
|
|
246
|
-
return isDirectory(def) ? [def] : [];
|
|
274
|
+
const def = containedExistingPath(root, defaultDir);
|
|
275
|
+
return def && isDirectory(def) ? [def] : [];
|
|
247
276
|
};
|
|
248
277
|
const commandPaths = componentPaths(manifest.commands, "commands");
|
|
249
278
|
const agentDirs = componentPaths(manifest.agents, "agents").filter(isDirectory);
|
|
250
|
-
|
|
279
|
+
const mcp = pluginMcp(root, manifest, manifestPath);
|
|
280
|
+
const unsupported = [];
|
|
281
|
+
if (manifest.hooks !== undefined || isDirectory(join(root, "hooks")) || isFile(join(root, "hooks.json")))
|
|
282
|
+
unsupported.push("hooks");
|
|
283
|
+
if (manifest.lspServers !== undefined || isFile(join(root, ".lsp.json")))
|
|
284
|
+
unsupported.push("lsp");
|
|
285
|
+
if (manifest.outputStyles !== undefined)
|
|
286
|
+
unsupported.push("output styles");
|
|
287
|
+
if (skillPaths.length === 0 && commandPaths.length === 0 && agentDirs.length === 0 && !mcp)
|
|
251
288
|
return null;
|
|
252
|
-
|
|
289
|
+
const version = meta.version ?? (typeof manifest.version === "string" ? manifest.version : null);
|
|
290
|
+
return {
|
|
291
|
+
name,
|
|
292
|
+
key: meta.key ?? name,
|
|
293
|
+
origin: meta.origin ?? "claude-code",
|
|
294
|
+
version,
|
|
295
|
+
root,
|
|
296
|
+
skillPaths,
|
|
297
|
+
commandPaths,
|
|
298
|
+
agentDirs,
|
|
299
|
+
mcp,
|
|
300
|
+
unsupported,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* A plugin's MCP servers, per Claude Code's rules: the manifest `mcpServers`
|
|
305
|
+
* field (an inline map, or a contained path to a JSON file with one), else
|
|
306
|
+
* `.mcp.json` at the plugin root. Malformed → null (fail-soft).
|
|
307
|
+
*/
|
|
308
|
+
function pluginMcp(root, manifest, manifestPath) {
|
|
309
|
+
const field = manifest.mcpServers;
|
|
310
|
+
if (field && typeof field === "object" && !Array.isArray(field)) {
|
|
311
|
+
return { sourcePath: manifestPath, servers: field };
|
|
312
|
+
}
|
|
313
|
+
const candidates = [];
|
|
314
|
+
if (typeof field === "string") {
|
|
315
|
+
const contained = containedExistingPath(root, field);
|
|
316
|
+
if (contained && isFile(contained))
|
|
317
|
+
candidates.push(contained);
|
|
318
|
+
}
|
|
319
|
+
const rootFile = containedExistingPath(root, ".mcp.json");
|
|
320
|
+
if (rootFile && isFile(rootFile))
|
|
321
|
+
candidates.push(rootFile);
|
|
322
|
+
for (const path of candidates) {
|
|
323
|
+
const parsed = readJsonObject(path);
|
|
324
|
+
const servers = parsed?.mcpServers;
|
|
325
|
+
if (servers && typeof servers === "object" && !Array.isArray(servers)) {
|
|
326
|
+
return { sourcePath: path, servers: servers };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
253
330
|
}
|
|
254
331
|
function resolveInstalledRoot(key, installed, marketplaces, realCwd) {
|
|
255
332
|
const entries = (installed.get(key) ?? []).filter((e) => isDirectory(e.installPath));
|
|
@@ -295,14 +372,40 @@ export function discoverClaudePlugins(deps) {
|
|
|
295
372
|
const user = [];
|
|
296
373
|
const project = [];
|
|
297
374
|
const seen = new Set();
|
|
375
|
+
// YAGNI-installed plugins are the user's own explicit action (`yagni
|
|
376
|
+
// plugin install`), so both scopes load without ceremony; a project-scope
|
|
377
|
+
// install applies only inside its repo.
|
|
378
|
+
for (const [key, entries] of Object.entries(deps.yagniLedger?.plugins ?? {})) {
|
|
379
|
+
const applicable = entries.filter((e) => {
|
|
380
|
+
if (!e.enabled || !isDirectory(e.installPath))
|
|
381
|
+
return false;
|
|
382
|
+
if (e.scope === "user")
|
|
383
|
+
return true;
|
|
384
|
+
const real = typeof e.projectPath === "string" ? realOrNull(e.projectPath) : null;
|
|
385
|
+
return !!real && (realCwd === real || realCwd.startsWith(real + sep));
|
|
386
|
+
});
|
|
387
|
+
const chosen = applicable.find((e) => e.scope === "project") ?? applicable[0];
|
|
388
|
+
if (!chosen)
|
|
389
|
+
continue;
|
|
390
|
+
const at = key.lastIndexOf("@");
|
|
391
|
+
const assets = pluginAssets(chosen.installPath, at > 0 ? key.slice(0, at) : key, {
|
|
392
|
+
key,
|
|
393
|
+
origin: "yagni",
|
|
394
|
+
version: chosen.version,
|
|
395
|
+
});
|
|
396
|
+
if (!assets)
|
|
397
|
+
continue;
|
|
398
|
+
seen.add(key);
|
|
399
|
+
user.push(assets);
|
|
400
|
+
}
|
|
298
401
|
for (const [key, state] of enabled) {
|
|
299
|
-
if (!state.enabled)
|
|
402
|
+
if (!state.enabled || seen.has(key))
|
|
300
403
|
continue;
|
|
301
404
|
const root = resolveInstalledRoot(key, installed, marketplaces, realCwd);
|
|
302
405
|
if (!root)
|
|
303
406
|
continue;
|
|
304
407
|
const at = key.lastIndexOf("@");
|
|
305
|
-
const assets = pluginAssets(root, at > 0 ? key.slice(0, at) : key);
|
|
408
|
+
const assets = pluginAssets(root, at > 0 ? key.slice(0, at) : key, { key, origin: "claude-code" });
|
|
306
409
|
if (!assets)
|
|
307
410
|
continue;
|
|
308
411
|
seen.add(key);
|
|
@@ -322,7 +425,12 @@ export function discoverClaudePlugins(deps) {
|
|
|
322
425
|
resolveInstalledRoot(key, installed, marketplaces, realCwd);
|
|
323
426
|
if (!root)
|
|
324
427
|
continue;
|
|
325
|
-
const
|
|
428
|
+
const declared = entry.version;
|
|
429
|
+
const assets = pluginAssets(root, entry.name, {
|
|
430
|
+
key,
|
|
431
|
+
origin: "repo-marketplace",
|
|
432
|
+
version: typeof declared === "string" ? declared : null,
|
|
433
|
+
});
|
|
326
434
|
if (!assets)
|
|
327
435
|
continue;
|
|
328
436
|
seen.add(key);
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ import { DISTRIBUTION } from "./distribution.js";
|
|
|
24
24
|
import { connectCommand } from "./connectClaudeCode.js";
|
|
25
25
|
import { goCommand } from "./goHeadless.js";
|
|
26
26
|
import { mcpCommand } from "./mcpCommand.js";
|
|
27
|
+
import { pluginCommand } from "./pluginCommand.js";
|
|
27
28
|
import { login } from "./login.js";
|
|
28
29
|
import { logout } from "./logout.js";
|
|
29
30
|
import { tokenCommand } from "./token.js";
|
|
@@ -247,10 +248,10 @@ async function runDefault(passthroughArgs) {
|
|
|
247
248
|
catch {
|
|
248
249
|
compat = { argv: [], env: {} };
|
|
249
250
|
}
|
|
250
|
-
// OTel export:
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
251
|
+
// OTel export: on only when an OTLP endpoint is configured — the user's
|
|
252
|
+
// env, the workspace's admin-set config (fetched fail-soft, cached), or the
|
|
253
|
+
// repo's .pi/settings.json, in that order. undefined keeps the launch
|
|
254
|
+
// untouched. See otel.ts for the policy.
|
|
254
255
|
const otel = await resolveOtelLaunchWithWorkspace({
|
|
255
256
|
env: process.env,
|
|
256
257
|
cwd: process.cwd(),
|
|
@@ -520,6 +521,10 @@ export const HELP_TEXT = [
|
|
|
520
521
|
" add-from-claude, and more — run `yagni mcp` for",
|
|
521
522
|
" details. OAuth servers authenticate via the",
|
|
522
523
|
" /mcp panel in a session.",
|
|
524
|
+
" yagni plugin <subcommand> Claude Code plugins from a marketplace: marketplace",
|
|
525
|
+
" add, install, uninstall, update, list — run",
|
|
526
|
+
" `yagni plugin` for details. Skills, commands,",
|
|
527
|
+
" agents, and bundled MCP servers load on launch.",
|
|
523
528
|
" yagni token Output the active environment's API token (for helpers).",
|
|
524
529
|
" yagni use <name> Switch the active environment (sticky).",
|
|
525
530
|
" Presets: prod, local. Others need --base-url <url>.",
|
|
@@ -542,6 +547,8 @@ export const HELP_TEXT = [
|
|
|
542
547
|
" /todos Show the agent's live task list.",
|
|
543
548
|
" /cost Session usage and credit headroom.",
|
|
544
549
|
" /mcp Manage MCP servers; authenticate OAuth servers.",
|
|
550
|
+
" /plugin Browse, install, and remove Claude Code plugins.",
|
|
551
|
+
" /reload-plugins Apply plugin changes without restarting.",
|
|
545
552
|
"",
|
|
546
553
|
"The active environment is sticky; `use` switches it (prod is the default).",
|
|
547
554
|
"Set YAGNI_BASE_URL to override the base URL for a single run.",
|
|
@@ -665,6 +672,11 @@ export async function main(argv) {
|
|
|
665
672
|
if (command === "mcp") {
|
|
666
673
|
return mcpCommand(rest);
|
|
667
674
|
}
|
|
675
|
+
// Claude Code plugin marketplaces: install/remove/update from YAGNI itself
|
|
676
|
+
// (state under ~/.yagni-code/plugins; Claude Code's own installs still mirror).
|
|
677
|
+
if (command === "plugin") {
|
|
678
|
+
return pluginCommand(rest);
|
|
679
|
+
}
|
|
668
680
|
// The headless pipeline entry. `go` is a real subcommand, not passthrough:
|
|
669
681
|
// the interactive run stays `/go` inside a session, and a `yagni go` without
|
|
670
682
|
// --headless is refused with usage rather than launched as an agent prompt.
|
package/dist/doctor.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Advisory checks (loose perms, missing `gh`) never flip the exit code.
|
|
14
14
|
*/
|
|
15
15
|
import { type TokenExpiryStatus } from "./launch.js";
|
|
16
|
-
import {
|
|
16
|
+
import { type OtelLaunchConfig } from "./otel.js";
|
|
17
17
|
import { type Profile } from "./profiles.js";
|
|
18
18
|
export type CheckStatus = "ok" | "warn" | "fail";
|
|
19
19
|
export interface CheckResult {
|
|
@@ -61,16 +61,25 @@ export declare function checkCliUpdate(probe: {
|
|
|
61
61
|
latest: string | null;
|
|
62
62
|
}): CheckResult;
|
|
63
63
|
export declare function checkGh(onPath: boolean): CheckResult;
|
|
64
|
+
/** One signal's verdict from the extension's probe (see telemetry/probe.ts). */
|
|
65
|
+
export interface OtelProbeSignal {
|
|
66
|
+
signal: "traces" | "metrics" | "logs";
|
|
67
|
+
url: string;
|
|
68
|
+
protocol: string;
|
|
69
|
+
ok: boolean;
|
|
70
|
+
error?: string;
|
|
71
|
+
}
|
|
64
72
|
/**
|
|
65
|
-
* Advisory OTel-export line: says whether sessions will stream
|
|
66
|
-
* OTLP collector, from which config source, and — when a collector is
|
|
67
|
-
* configured — whether
|
|
68
|
-
*
|
|
69
|
-
* most machines have no collector, and
|
|
73
|
+
* Advisory OTel-export line: says whether sessions will stream telemetry to
|
|
74
|
+
* an OTLP collector, from which config source, and — when a collector is
|
|
75
|
+
* configured — whether it ACCEPTED a real record on each signal (a wrong API
|
|
76
|
+
* key, a wrong path, or a protocol mismatch shows up as the collector's own
|
|
77
|
+
* error). Never flips the exit code — most machines have no collector, and
|
|
78
|
+
* that is the healthy default.
|
|
70
79
|
*/
|
|
71
80
|
export declare function checkOtelExport(config: OtelLaunchConfig | undefined, deps?: {
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
probe?: (env: NodeJS.ProcessEnv) => Promise<OtelProbeSignal[]>;
|
|
82
|
+
baseEnv?: NodeJS.ProcessEnv;
|
|
74
83
|
}): Promise<CheckResult>;
|
|
75
84
|
/** Config-only MCP snapshot (no connections — health checks live in `mcp list`/`get`). */
|
|
76
85
|
export interface McpProbe {
|
|
@@ -120,6 +129,10 @@ export interface DoctorDeps {
|
|
|
120
129
|
probeLatestVersion?: () => Promise<string | null>;
|
|
121
130
|
/** MCP config snapshot (config-only, no connections). */
|
|
122
131
|
probeMcp?: () => Promise<McpProbe>;
|
|
132
|
+
/** OTel gate resolution (env / workspace / repo settings); tests stub it. */
|
|
133
|
+
resolveOtel?: (profile: Profile) => Promise<OtelLaunchConfig | undefined>;
|
|
134
|
+
/** OTel export probe: one real record per signal against the session env. */
|
|
135
|
+
probeOtel?: (env: NodeJS.ProcessEnv) => Promise<OtelProbeSignal[]>;
|
|
123
136
|
log?: (msg: string) => void;
|
|
124
137
|
}
|
|
125
138
|
/** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
|
package/dist/doctor.js
CHANGED
|
@@ -17,8 +17,8 @@ import { delimiter, join } from "node:path";
|
|
|
17
17
|
import { credentialsDir } from "./credentials.js";
|
|
18
18
|
import { currentCliVersion, fetchLatestVersion, isNewerVersion } from "./upgrade.js";
|
|
19
19
|
import { classifyTokenExpiry } from "./launch.js";
|
|
20
|
-
import {
|
|
21
|
-
import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
|
|
20
|
+
import { otelChildEnv, resolveOtelLaunchWithWorkspace } from "./otel.js";
|
|
21
|
+
import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir, resolveTelemetryProbePath } from "./paths.js";
|
|
22
22
|
import { readActiveProfile } from "./profiles.js";
|
|
23
23
|
import { resolveMcpConfigPath } from "./mcpCommand.js";
|
|
24
24
|
// ── Pure check builders ─────────────────────────────────────────────────────
|
|
@@ -201,11 +201,22 @@ export function checkGh(onPath) {
|
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
203
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
|
|
204
|
+
* Run the extension's telemetry probe against the exact env a session would
|
|
205
|
+
* get: one real span, one metric point, one log record, each reported with
|
|
206
|
+
* the collector's actual verdict. Dynamic import so `doctor` on a machine
|
|
207
|
+
* with no collector never loads the OTel SDK graph.
|
|
208
|
+
*/
|
|
209
|
+
async function defaultProbeOtel(env) {
|
|
210
|
+
const mod = (await import(resolveTelemetryProbePath()));
|
|
211
|
+
return (await mod.probeTelemetryExport(env)).results;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Advisory OTel-export line: says whether sessions will stream telemetry to
|
|
215
|
+
* an OTLP collector, from which config source, and — when a collector is
|
|
216
|
+
* configured — whether it ACCEPTED a real record on each signal (a wrong API
|
|
217
|
+
* key, a wrong path, or a protocol mismatch shows up as the collector's own
|
|
218
|
+
* error). Never flips the exit code — most machines have no collector, and
|
|
219
|
+
* that is the healthy default.
|
|
209
220
|
*/
|
|
210
221
|
export async function checkOtelExport(config, deps = {}) {
|
|
211
222
|
if (!config) {
|
|
@@ -222,23 +233,32 @@ export async function checkOtelExport(config, deps = {}) {
|
|
|
222
233
|
? "workspace settings"
|
|
223
234
|
: ".pi/settings.json";
|
|
224
235
|
const base = `on → ${config.endpoint} (${source}, metadata-only)`;
|
|
225
|
-
const
|
|
236
|
+
const baseEnv = deps.baseEnv ?? process.env;
|
|
237
|
+
const env = { ...baseEnv, ...otelChildEnv(config, baseEnv) };
|
|
238
|
+
const doProbe = deps.probe ?? defaultProbeOtel;
|
|
226
239
|
try {
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
240
|
+
const results = await doProbe(env);
|
|
241
|
+
const accepted = results.filter((r) => r.ok).map((r) => r.signal);
|
|
242
|
+
const failed = results.filter((r) => !r.ok);
|
|
243
|
+
const summary = results.map((r) => `${r.signal} ${r.ok ? "accepted" : "FAILED"}`).join(", ");
|
|
244
|
+
if (failed.length === 0) {
|
|
245
|
+
return {
|
|
230
246
|
name: "otel export (optional)",
|
|
231
247
|
status: "ok",
|
|
232
|
-
detail: `${base}
|
|
233
|
-
required: false,
|
|
234
|
-
}
|
|
235
|
-
: {
|
|
236
|
-
name: "otel export (optional)",
|
|
237
|
-
status: "warn",
|
|
238
|
-
detail: `${base}, endpoint not reachable`,
|
|
239
|
-
hint: "the collector is down, unreachable from this machine, or the URL is wrong — traces will not export until it answers",
|
|
248
|
+
detail: `${base}; ${summary}`,
|
|
240
249
|
required: false,
|
|
241
250
|
};
|
|
251
|
+
}
|
|
252
|
+
const reasons = failed.map((r) => `${r.signal} → ${r.url} (${r.protocol}): ${r.error ?? "rejected"}`).join("; ");
|
|
253
|
+
return {
|
|
254
|
+
name: "otel export (optional)",
|
|
255
|
+
status: "warn",
|
|
256
|
+
detail: `${base}; ${summary}`,
|
|
257
|
+
hint: `${reasons}. A 401/403 means the collector key is wrong, a 404 the path or site is wrong, ` +
|
|
258
|
+
`a connection error the collector is unreachable from this machine` +
|
|
259
|
+
(accepted.length > 0 ? `; ${accepted.join(" and ")} still export` : ""),
|
|
260
|
+
required: false,
|
|
261
|
+
};
|
|
242
262
|
}
|
|
243
263
|
catch {
|
|
244
264
|
// The probe itself errored (not "endpoint down" — the check could not
|
|
@@ -247,8 +267,8 @@ export async function checkOtelExport(config, deps = {}) {
|
|
|
247
267
|
return {
|
|
248
268
|
name: "otel export (optional)",
|
|
249
269
|
status: "warn",
|
|
250
|
-
detail: `${base},
|
|
251
|
-
hint: "the
|
|
270
|
+
detail: `${base}, export probe failed`,
|
|
271
|
+
hint: "the export probe could not run (the bundled extension is missing or the OTel SDK failed to load) — export is configured but unverified; sessions may still export normally",
|
|
252
272
|
required: false,
|
|
253
273
|
};
|
|
254
274
|
}
|
|
@@ -402,7 +422,7 @@ async function defaultProbeMcp(env = process.env) {
|
|
|
402
422
|
const repoRoot = mod.resolveProjectRoot(process.cwd());
|
|
403
423
|
const { state } = mod.readProjectApproval(repoRoot);
|
|
404
424
|
const undecided = loaded.servers
|
|
405
|
-
.filter((s) => s.scope === "project" && mod.decisionFor(state, s.name) === "undecided")
|
|
425
|
+
.filter((s) => (s.scope === "project" || s.gated === true) && mod.decisionFor(state, s.name) === "undecided")
|
|
406
426
|
.map((s) => s.name);
|
|
407
427
|
return {
|
|
408
428
|
disabled: false,
|
|
@@ -516,12 +536,17 @@ export async function gatherChecks(deps = {}) {
|
|
|
516
536
|
checks.push(checkStateDir(probeStateDir()));
|
|
517
537
|
checks.push(checkGh(ghOnPath()));
|
|
518
538
|
checks.push(checkMcpConfig(await probeMcp()));
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
539
|
+
const resolveOtel = deps.resolveOtel ??
|
|
540
|
+
((p) => resolveOtelLaunchWithWorkspace({
|
|
541
|
+
env: process.env,
|
|
542
|
+
cwd: process.cwd(),
|
|
543
|
+
creds: p.token ? { baseUrl: p.baseUrl, token: p.token } : null,
|
|
544
|
+
profileName: p.name,
|
|
545
|
+
}));
|
|
546
|
+
checks.push(await checkOtelExport(await resolveOtel(profile), {
|
|
547
|
+
...(deps.probeOtel ? { probe: deps.probeOtel } : {}),
|
|
548
|
+
baseEnv: process.env,
|
|
549
|
+
}));
|
|
525
550
|
return checks;
|
|
526
551
|
}
|
|
527
552
|
/**
|
|
@@ -24,7 +24,11 @@ import { logAskQuestion } from "./diagnostics.js";
|
|
|
24
24
|
const TOOL_NAME = "ask_user_question";
|
|
25
25
|
/** Claude mirrors this as ASK_USER_QUESTION_TOOL_CHIP_WIDTH. */
|
|
26
26
|
const CHIP_WIDTH = 12;
|
|
27
|
-
/**
|
|
27
|
+
/** Cancel an unanswered question after 2 minutes. This is the structured
|
|
28
|
+
* QUESTION tool's own UX cap — NOT the permission-ask contract (gate asks
|
|
29
|
+
* wait indefinitely per Claude Code parity; see agent-safety.md). A stale
|
|
30
|
+
* question auto-cancels so the agent's turn cannot hang on a chip nobody
|
|
31
|
+
* is looking at; the user can always re-ask. */
|
|
28
32
|
const ASK_TIMEOUT_MS = 120_000;
|
|
29
33
|
/** Sentinel value representing the "Other" row in the multi-select toggle set. */
|
|
30
34
|
const OTHER_KEY = "__other__";
|
|
@@ -523,7 +527,8 @@ async function askOne(ctx, q, qIndex) {
|
|
|
523
527
|
return;
|
|
524
528
|
}
|
|
525
529
|
ctx.signal?.addEventListener("abort", onAbort, { once: true });
|
|
526
|
-
// Best-effort timeout
|
|
530
|
+
// Best-effort timeout: this surface's own 2-minute question cap (the
|
|
531
|
+
// permission-gate ask deliberately has none — see ASK_TIMEOUT_MS above).
|
|
527
532
|
const timer = setTimeout(() => settle({ status: "cancelled" }), ASK_TIMEOUT_MS);
|
|
528
533
|
void ctx.ui
|
|
529
534
|
.custom((_tui, theme, keybindings, done) => {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* pattern) so tests run against plain text. Renderer exceptions are swallowed
|
|
25
25
|
* by pi (degrading to the built-in fallback), so everything stays total.
|
|
26
26
|
*/
|
|
27
|
-
import { SettingsManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { SettingsManager, type ExtensionAPI, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
28
28
|
import type { RenderTheme } from "./subagentRender.js";
|
|
29
29
|
import { ToolRunTracker } from "./toolRuns.js";
|
|
30
30
|
/** Lines of write content shown collapsed (mirrors Claude Code's preview). */
|
|
@@ -87,6 +87,17 @@ export interface RegisterCondensedToolsDeps {
|
|
|
87
87
|
* image autoResize. Injectable so tests never touch the config dir.
|
|
88
88
|
*/
|
|
89
89
|
loadSettings?: (cwd: string) => SettingsManager;
|
|
90
|
+
/**
|
|
91
|
+
* sandbox composition: when present, the bash definition is passed
|
|
92
|
+
* through this wrap before registration — the sandbox adds its schema field
|
|
93
|
+
* (dangerouslyDisableSandbox), wrap-on-execute, and violation annotation
|
|
94
|
+
* while condensed keeps its rendering. The compose fn is built by
|
|
95
|
+
* sandbox/session.ts (makeSandboxBashComposition) and is a no-op when the
|
|
96
|
+
* sandbox is disabled. Condensed registers LAST among the bash-defining
|
|
97
|
+
* sites, so composing HERE is what makes the sandbox actually reach the
|
|
98
|
+
* model's tool calls.
|
|
99
|
+
*/
|
|
100
|
+
wrapBash?: (def: ToolDefinition) => ToolDefinition;
|
|
90
101
|
}
|
|
91
102
|
/**
|
|
92
103
|
* Re-register the seven built-ins with condensed renderers. Returns the
|
|
@@ -291,15 +291,23 @@ export function registerCondensedTools(pi, deps = {}) {
|
|
|
291
291
|
catch {
|
|
292
292
|
toolOptions = { bash: {}, read: {} };
|
|
293
293
|
}
|
|
294
|
-
const buildDefinitions = (cwd) =>
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
294
|
+
const buildDefinitions = (cwd) => {
|
|
295
|
+
const defs = {
|
|
296
|
+
read: createReadToolDefinition(cwd, toolOptions.read),
|
|
297
|
+
bash: createBashToolDefinition(cwd, toolOptions.bash),
|
|
298
|
+
edit: createEditToolDefinition(cwd),
|
|
299
|
+
write: createWriteToolDefinition(cwd),
|
|
300
|
+
grep: createGrepToolDefinition(cwd),
|
|
301
|
+
find: createFindToolDefinition(cwd),
|
|
302
|
+
ls: createLsToolDefinition(cwd),
|
|
303
|
+
};
|
|
304
|
+
// the sandbox wraps the bash DEFINITION (not the registration
|
|
305
|
+
// wrapper) so the cwd-delegating execute below still lands on the
|
|
306
|
+
// sandbox-aware tool for every cwd the session visits.
|
|
307
|
+
if (defs.bash && deps.wrapBash)
|
|
308
|
+
defs.bash = deps.wrapBash(defs.bash);
|
|
309
|
+
return defs;
|
|
310
|
+
};
|
|
303
311
|
const definitionsByCwd = new Map();
|
|
304
312
|
const definitionsFor = (cwd) => {
|
|
305
313
|
let defs = definitionsByCwd.get(cwd);
|
|
@@ -127,6 +127,12 @@ export interface ContextBrief {
|
|
|
127
127
|
* The opt-in mining beat fires ONLY on an explicit 0.
|
|
128
128
|
*/
|
|
129
129
|
repoDecisionCount?: number;
|
|
130
|
+
/**
|
|
131
|
+
* The signed-in developer's account email (additive; absent on older
|
|
132
|
+
* backends). Feeds the OTel export's `user.email` attribute when the
|
|
133
|
+
* launcher did not already forward one.
|
|
134
|
+
*/
|
|
135
|
+
userEmail?: string;
|
|
130
136
|
}
|
|
131
137
|
/**
|
|
132
138
|
* Attribution headers for the model proxy (YAG-471). On the SERVER side these
|
|
@@ -26,8 +26,8 @@ export interface HookEntry {
|
|
|
26
26
|
export interface HookGroup {
|
|
27
27
|
matcher?: string;
|
|
28
28
|
hooks: HookEntry[];
|
|
29
|
-
/** Where this group was loaded from. Project
|
|
30
|
-
_source?: "user" | "project";
|
|
29
|
+
/** Where this group was loaded from. Project AND local groups are gated on workspace trust. */
|
|
30
|
+
_source?: "user" | "project" | "local";
|
|
31
31
|
}
|
|
32
32
|
/** The hooks section of config.json. */
|
|
33
33
|
export type HooksConfig = Record<string, HookGroup[]>;
|
|
@@ -55,7 +55,7 @@ export interface HookRunner {
|
|
|
55
55
|
preToolUse(toolName: string, input: Record<string, unknown>, cwd: string, trusted?: boolean): Promise<PreToolUseHookResult>;
|
|
56
56
|
permissionRequest(toolName: string, input: Record<string, unknown>, cwd: string, trusted?: boolean): Promise<PermissionRequestHookResult>;
|
|
57
57
|
}
|
|
58
|
-
/** Read and merge hooks config from user and
|
|
58
|
+
/** Read and merge hooks config from user, project, and local files. Pure I/O, fail-soft. */
|
|
59
59
|
export declare function loadHooksConfig(userHome?: string, cwd?: string, env?: NodeJS.ProcessEnv): HooksConfig;
|
|
60
60
|
/**
|
|
61
61
|
* Check if a matcher matches a tool name. Matches Claude Code / Codex:
|
package/dist/extension/hooks.js
CHANGED
|
@@ -36,7 +36,7 @@ const SUPPORTED_EVENTS = [
|
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
37
37
|
// Config loading
|
|
38
38
|
// ---------------------------------------------------------------------------
|
|
39
|
-
/** Read and merge hooks config from user and
|
|
39
|
+
/** Read and merge hooks config from user, project, and local files. Pure I/O, fail-soft. */
|
|
40
40
|
export function loadHooksConfig(userHome = homedir(), cwd = process.cwd(), env = process.env) {
|
|
41
41
|
if (env.YAGNI_CODE_EVAL_MODE === "1")
|
|
42
42
|
return {};
|
|
@@ -47,6 +47,13 @@ export function loadHooksConfig(userHome = homedir(), cwd = process.cwd(), env =
|
|
|
47
47
|
// Project-level: .yagni-code/config.json — tagged for trust gating at execution time
|
|
48
48
|
const projectPath = join(cwd, ".yagni-code", "config.json");
|
|
49
49
|
mergeHooksFromFile(merged, projectPath, "project");
|
|
50
|
+
// Local-level: .yagni-code/config.local.json — the personal per-project
|
|
51
|
+
// tier. Trust-gated like project (NOT always-trusted): a hostile repo can
|
|
52
|
+
// COMMIT a local file — the gitignore convention only covers untracked
|
|
53
|
+
// files. Deliberately stricter than Claude Code, which does not
|
|
54
|
+
// trust-gate its localSettings hooks.
|
|
55
|
+
const localPath = join(cwd, ".yagni-code", "config.local.json");
|
|
56
|
+
mergeHooksFromFile(merged, localPath, "local");
|
|
50
57
|
return merged;
|
|
51
58
|
}
|
|
52
59
|
function mergeHooksFromFile(merged, path, source) {
|
|
@@ -68,8 +75,20 @@ function mergeHooksFromFile(merged, path, source) {
|
|
|
68
75
|
}
|
|
69
76
|
}
|
|
70
77
|
}
|
|
71
|
-
catch {
|
|
72
|
-
// Fail-soft: missing or malformed config is
|
|
78
|
+
catch (err) {
|
|
79
|
+
// Fail-soft: missing or malformed config is skipped — and LOGGED: a
|
|
80
|
+
// corrupt local/project config silently dropping its hooks (including
|
|
81
|
+
// deny-enforcing ones) with zero signal is undiagnosable. Path + error
|
|
82
|
+
// class only, matching loadConfig.ts's warning behavior.
|
|
83
|
+
logEvent({
|
|
84
|
+
source: "hooks",
|
|
85
|
+
level: "warn",
|
|
86
|
+
event: "config_warning",
|
|
87
|
+
fields: {
|
|
88
|
+
path,
|
|
89
|
+
error: err instanceof Error ? err.constructor.name : typeof err,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
73
92
|
}
|
|
74
93
|
}
|
|
75
94
|
function isValidHookGroup(value) {
|
|
@@ -297,11 +316,17 @@ function logHookEvent(env, payload) {
|
|
|
297
316
|
fields,
|
|
298
317
|
});
|
|
299
318
|
}
|
|
300
|
-
/**
|
|
319
|
+
/**
|
|
320
|
+
* Filter hook groups by workspace trust: project AND local groups are
|
|
321
|
+
* skipped when untrusted. Local is gated exactly like project (not like the
|
|
322
|
+
* always-trusted user tier) because a hostile repo can COMMIT a
|
|
323
|
+
* config.local.json — the global-gitignore convention only covers
|
|
324
|
+
* untracked files. Deliberately stricter than Claude Code's localSettings.
|
|
325
|
+
*/
|
|
301
326
|
function filterByTrust(groups, isTrusted) {
|
|
302
327
|
if (isTrusted)
|
|
303
328
|
return groups;
|
|
304
|
-
return groups.filter((g) => g._source !== "project");
|
|
329
|
+
return groups.filter((g) => g._source !== "project" && g._source !== "local");
|
|
305
330
|
}
|
|
306
331
|
/**
|
|
307
332
|
* Create a HookRunner for injection into the permission gate. The `isTrusted`
|