@alisio/core 0.1.0-alpha.5 → 0.1.0-alpha.6
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/application.d.ts +18 -4
- package/dist/application.js +114 -7
- package/dist/config.d.ts +12 -1
- package/dist/config.js +79 -7
- package/dist/core/runner.d.ts +8 -6
- package/dist/core/runner.js +15 -12
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/mcp/connector.d.ts +2 -0
- package/dist/mcp/connector.js +9 -1
- package/dist/plugins/host.js +7 -1
- package/dist/plugins/install.d.ts +100 -0
- package/dist/plugins/install.js +322 -0
- package/dist/resources/context.js +1 -1
- package/dist/runtime/modules.d.ts +3 -1
- package/dist/runtime/modules.js +9 -2
- package/dist/runtime/process.d.ts +9 -0
- package/dist/runtime/process.js +18 -0
- package/dist/tools/standard.js +14 -3
- package/dist/version.d.ts +7 -0
- package/dist/version.js +26 -0
- package/package.json +1 -1
package/dist/application.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AvailableProviderModel, ModelInfo, ModelProvider, Plugin, PluginCategory, ResolvedProviderModel, RunEvent } from "@alisio/sdk";
|
|
1
|
+
import type { AvailableProviderModel, ModelInfo, ModelProvider, Plugin, PluginCategory, ProviderConfigurationValue, ResolvedProviderModel, RunEvent } from "@alisio/sdk";
|
|
2
2
|
import type { ApprovalHandler } from "./core/contracts.ts";
|
|
3
3
|
import { ToolRegistry } from "./core/registry.ts";
|
|
4
4
|
import { AgentRunner } from "./core/runner.ts";
|
|
@@ -142,11 +142,25 @@ export declare function createApplication(options?: AppOptions): Promise<{
|
|
|
142
142
|
mcp: McpConnector;
|
|
143
143
|
/** Current-process MCP grant; deliberately independent from persisted server enablement. */
|
|
144
144
|
mcpRuntimePermission(): "granted" | "not-granted" | "read-only";
|
|
145
|
+
/** Whether global MCP consent (`mcp.allow`) is persisted for this user right now. */
|
|
146
|
+
mcpAllowPersisted(): boolean;
|
|
147
|
+
/**
|
|
148
|
+
* Sanitized per-server failures collected while auto-connecting enabled servers at startup
|
|
149
|
+
* (only when runtime permission was granted). Never includes credentials or command paths.
|
|
150
|
+
*/
|
|
151
|
+
mcpStartupFailures(): string[];
|
|
145
152
|
/** Host-owned interactive consent boundary. Headless callers remain blocked unless flagged. */
|
|
146
153
|
grantMcpRuntimePermission(request: {
|
|
147
154
|
source: "interactive-tui";
|
|
148
155
|
confirmed: boolean;
|
|
149
156
|
}): "granted" | "not-granted" | "read-only";
|
|
157
|
+
/** Persist global consent atomically, then grant runtime permission for this process. */
|
|
158
|
+
rememberGlobalMcpConsent(): Promise<void>;
|
|
159
|
+
/**
|
|
160
|
+
* Clear the persisted global consent atomically and drop the runtime permission (disconnecting
|
|
161
|
+
* servers as cleanup does). An explicit this-run `--allow-mcp` flag keeps the grant alive.
|
|
162
|
+
*/
|
|
163
|
+
revokeGlobalMcpConsent(): Promise<void>;
|
|
150
164
|
/** Persist an MCP toggle in its defining layer, then safely update this process. */
|
|
151
165
|
setMcpEnabled(id: string, enabled: boolean, connect?: boolean): Promise<import("./index.ts").McpServerInfo>;
|
|
152
166
|
provider: ActiveProvider;
|
|
@@ -155,7 +169,7 @@ export declare function createApplication(options?: AppOptions): Promise<{
|
|
|
155
169
|
readonly providerError: string | undefined;
|
|
156
170
|
readonly providerInfo: {
|
|
157
171
|
id: string;
|
|
158
|
-
profile: Record<string,
|
|
172
|
+
profile: Record<string, ProviderConfigurationValue>;
|
|
159
173
|
persisted: boolean;
|
|
160
174
|
profileName: string;
|
|
161
175
|
} | {
|
|
@@ -169,8 +183,8 @@ export declare function createApplication(options?: AppOptions): Promise<{
|
|
|
169
183
|
contextWindow: (model: string) => number | undefined;
|
|
170
184
|
/** Effective context budget + compaction point shared by the runner and the TUI bar. */
|
|
171
185
|
contextBudget: (model: string) => {
|
|
172
|
-
total
|
|
173
|
-
basis: "window" | "
|
|
186
|
+
total?: number;
|
|
187
|
+
basis: "window" | "unknown";
|
|
174
188
|
compactionAt: number;
|
|
175
189
|
};
|
|
176
190
|
loadModels: (signal: AbortSignal) => Promise<ModelInfo[]>;
|
package/dist/application.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { configFile, configHome, loadConfigWithProvenance, overridesSavedProviderProfile, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
|
|
3
|
+
import { configFile, configHome, loadConfigWithProvenance, overridesSavedProviderProfile, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
|
|
4
4
|
import { completeText } from "./core/compaction.js";
|
|
5
5
|
import { ToolRegistry } from "./core/registry.js";
|
|
6
6
|
import { AgentRunner } from "./core/runner.js";
|
|
@@ -28,6 +28,17 @@ const nonBlank = (value) => {
|
|
|
28
28
|
const normalized = value?.trim();
|
|
29
29
|
return normalized || undefined;
|
|
30
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Per-profile optional context-window override, in tokens (for local OpenAI-compatible servers
|
|
33
|
+
* like llama.cpp whose `GET /models` omits `context_window`). Numbers are used directly; strings
|
|
34
|
+
* (for example hand-edited providers.json) are parsed. Absent or unparseable means "no override".
|
|
35
|
+
*/
|
|
36
|
+
const parseProfileContextWindow = (value) => {
|
|
37
|
+
if (value === undefined)
|
|
38
|
+
return undefined;
|
|
39
|
+
const window = typeof value === "number" ? value : Number(String(value).trim());
|
|
40
|
+
return Number.isFinite(window) && window > 0 ? window : undefined;
|
|
41
|
+
};
|
|
31
42
|
export async function createApplication(options = {}) {
|
|
32
43
|
const cwd = resolve(options.cwd ?? process.cwd()), workspace = await findWorkspace(cwd);
|
|
33
44
|
const { config, provenance: configProvenance } = await loadConfigWithProvenance(workspace, {
|
|
@@ -37,16 +48,20 @@ export async function createApplication(options = {}) {
|
|
|
37
48
|
baseURL: options.baseURL,
|
|
38
49
|
apiMode: options.apiMode,
|
|
39
50
|
});
|
|
51
|
+
// Global-only consent: `mcp.allow` is read from the user layer; the merged config never carries
|
|
52
|
+
// a project value (see loadConfigWithProvenance), so a project cannot grant itself MCP consent.
|
|
53
|
+
const mcpAllow = config.mcp.allow === true;
|
|
54
|
+
let persistedMcpAllow = mcpAllow;
|
|
40
55
|
const store = new SQLiteStore(options.db ?? join(stateHome(), "sessions.sqlite"));
|
|
41
56
|
const registry = new ToolRegistry(), skills = new Skills({ overrides: config.skillOverrides }), context = new ProjectContext(workspace, {
|
|
42
57
|
globalDir: configHome(),
|
|
43
58
|
cwd,
|
|
44
59
|
claudeMdFallback: config.context.claudeMdFallback,
|
|
45
60
|
maxBytes: config.context.maxBytes,
|
|
46
|
-
}), providers = new ProviderRegistry(), plugins = new PluginHost(registry, store, config.pluginHooks, providers), mcp = new McpConnector(config.mcp.servers, registry, workspace, options.readOnly ? "read-only" : options.allowMcp ? "allowed" : "disabled", config.mcpSources);
|
|
61
|
+
}), providers = new ProviderRegistry(), plugins = new PluginHost(registry, store, config.pluginHooks, providers), mcp = new McpConnector(config.mcp.servers, registry, workspace, options.readOnly ? "read-only" : options.allowMcp || mcpAllow ? "allowed" : "disabled", config.mcpSources);
|
|
47
62
|
let mcpRuntimePermission = options.readOnly
|
|
48
63
|
? "read-only"
|
|
49
|
-
: options.allowMcp
|
|
64
|
+
: options.allowMcp || mcpAllow
|
|
50
65
|
? "granted"
|
|
51
66
|
: "not-granted";
|
|
52
67
|
const herdr = options.noHerdr
|
|
@@ -220,7 +235,9 @@ export async function createApplication(options = {}) {
|
|
|
220
235
|
config: config.websearch,
|
|
221
236
|
resolveExtension: () => plugins.extensions.resolve("websearch"),
|
|
222
237
|
});
|
|
223
|
-
|
|
238
|
+
const { registerPluginInstallTool } = await import("./plugins/install.js");
|
|
239
|
+
registerPluginInstallTool(registry, { readOnly: !!options.readOnly });
|
|
240
|
+
if ((options.allowMcp || mcpAllow) && !options.readOnly)
|
|
224
241
|
mcp.register();
|
|
225
242
|
if (options.allowAgents && !options.readOnly)
|
|
226
243
|
herdr.registerTools(registry);
|
|
@@ -297,9 +314,39 @@ export async function createApplication(options = {}) {
|
|
|
297
314
|
throw new Error('websearch.provider is "native" but the active provider does not support native search');
|
|
298
315
|
}
|
|
299
316
|
const models = new Map();
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
317
|
+
// Lazily (re)loads the active provider's catalog exactly once, so the ACTIVE model's
|
|
318
|
+
// contextWindow becomes known for the context bar without waiting for an explicit
|
|
319
|
+
// /model picker or autocomplete call. Failed or absent discovery stays retryable.
|
|
320
|
+
let modelsLoading;
|
|
321
|
+
const ensureModels = () => {
|
|
322
|
+
if (!provider.listModels || modelsLoading)
|
|
323
|
+
return modelsLoading;
|
|
324
|
+
modelsLoading = loadModels(AbortSignal.timeout(10_000));
|
|
325
|
+
modelsLoading.catch(() => {
|
|
326
|
+
if (modelsLoading)
|
|
327
|
+
modelsLoading = undefined;
|
|
328
|
+
});
|
|
329
|
+
return modelsLoading;
|
|
330
|
+
};
|
|
331
|
+
// Priority: explicit configuration for the configured model, then the active saved profile's
|
|
332
|
+
// per-model override (user intent for local servers that omit context_window), then GET /models.
|
|
333
|
+
const contextWindow = (model) => {
|
|
334
|
+
const explicit = model === config.provider.model ? config.provider.contextWindow : undefined;
|
|
335
|
+
if (explicit !== undefined)
|
|
336
|
+
return explicit;
|
|
337
|
+
const override = providerInfo?.persisted && model === provider.model
|
|
338
|
+
? parseProfileContextWindow(providerInfo.profile.contextWindow)
|
|
339
|
+
: undefined;
|
|
340
|
+
if (override !== undefined)
|
|
341
|
+
return override;
|
|
342
|
+
const known = models.get(model)?.contextWindow;
|
|
343
|
+
if (known !== undefined)
|
|
344
|
+
return known;
|
|
345
|
+
// Background prime: this call stays synchronous (the runner and the bar are sync), and
|
|
346
|
+
// once the catalog lands the map is populated for every later lookup.
|
|
347
|
+
void ensureModels();
|
|
348
|
+
return undefined;
|
|
349
|
+
};
|
|
303
350
|
const loadModels = async (signal) => {
|
|
304
351
|
if (!provider.listModels)
|
|
305
352
|
return [];
|
|
@@ -431,6 +478,7 @@ export async function createApplication(options = {}) {
|
|
|
431
478
|
external: !options.readOnly &&
|
|
432
479
|
(!!options.allowExternal ||
|
|
433
480
|
!!options.allowMcp ||
|
|
481
|
+
mcpAllow ||
|
|
434
482
|
!!options.allowAgents ||
|
|
435
483
|
plugins.externalCount > 0 ||
|
|
436
484
|
registry.list().some((t) => t.name.startsWith("p_"))),
|
|
@@ -508,6 +556,24 @@ export async function createApplication(options = {}) {
|
|
|
508
556
|
}
|
|
509
557
|
return expanded;
|
|
510
558
|
};
|
|
559
|
+
// Startup auto-connect: when runtime permission is granted (--allow-mcp or global mcp.allow),
|
|
560
|
+
// connect every enabled server exactly like pressing Connect for each. Failures are per-server,
|
|
561
|
+
// sanitized and never fatal to startup.
|
|
562
|
+
const mcpStartupFailures = [];
|
|
563
|
+
if (mcpRuntimePermission === "granted") {
|
|
564
|
+
for (const [name, server] of Object.entries(config.mcp.servers)) {
|
|
565
|
+
if (!server.enabled)
|
|
566
|
+
continue;
|
|
567
|
+
try {
|
|
568
|
+
await mcp.connect(name, AbortSignal.timeout(15_000));
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
const diagnostic = mcp.info(name).diagnostic ??
|
|
572
|
+
"Connection failed; inspect the server separately for details";
|
|
573
|
+
mcpStartupFailures.push(`MCP server "${name}" did not auto-connect: ${diagnostic}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
511
577
|
return {
|
|
512
578
|
workspace,
|
|
513
579
|
config,
|
|
@@ -595,6 +661,17 @@ export async function createApplication(options = {}) {
|
|
|
595
661
|
mcpRuntimePermission() {
|
|
596
662
|
return mcpRuntimePermission;
|
|
597
663
|
},
|
|
664
|
+
/** Whether global MCP consent (`mcp.allow`) is persisted for this user right now. */
|
|
665
|
+
mcpAllowPersisted() {
|
|
666
|
+
return persistedMcpAllow;
|
|
667
|
+
},
|
|
668
|
+
/**
|
|
669
|
+
* Sanitized per-server failures collected while auto-connecting enabled servers at startup
|
|
670
|
+
* (only when runtime permission was granted). Never includes credentials or command paths.
|
|
671
|
+
*/
|
|
672
|
+
mcpStartupFailures() {
|
|
673
|
+
return [...mcpStartupFailures];
|
|
674
|
+
},
|
|
598
675
|
/** Host-owned interactive consent boundary. Headless callers remain blocked unless flagged. */
|
|
599
676
|
grantMcpRuntimePermission(request) {
|
|
600
677
|
if (!request.confirmed)
|
|
@@ -610,6 +687,35 @@ export async function createApplication(options = {}) {
|
|
|
610
687
|
mcpRuntimePermission = "granted";
|
|
611
688
|
return mcpRuntimePermission;
|
|
612
689
|
},
|
|
690
|
+
/** Persist global consent atomically, then grant runtime permission for this process. */
|
|
691
|
+
async rememberGlobalMcpConsent() {
|
|
692
|
+
if (options.readOnly)
|
|
693
|
+
throw new Error("MCP is unavailable under --read-only");
|
|
694
|
+
await setGlobalMcpAllow({ allow: true });
|
|
695
|
+
persistedMcpAllow = true;
|
|
696
|
+
this.grantMcpRuntimePermission({ source: "interactive-tui", confirmed: true });
|
|
697
|
+
},
|
|
698
|
+
/**
|
|
699
|
+
* Clear the persisted global consent atomically and drop the runtime permission (disconnecting
|
|
700
|
+
* servers as cleanup does). An explicit this-run `--allow-mcp` flag keeps the grant alive.
|
|
701
|
+
*/
|
|
702
|
+
async revokeGlobalMcpConsent() {
|
|
703
|
+
if (options.readOnly)
|
|
704
|
+
throw new Error("MCP is unavailable under --read-only");
|
|
705
|
+
await setGlobalMcpAllow({ allow: false });
|
|
706
|
+
persistedMcpAllow = false;
|
|
707
|
+
if (options.allowMcp)
|
|
708
|
+
return;
|
|
709
|
+
if (mcpRuntimePermission !== "granted")
|
|
710
|
+
return;
|
|
711
|
+
await mcp.revokeRuntimePermission();
|
|
712
|
+
mcpRuntimePermission = "not-granted";
|
|
713
|
+
runtimePolicy.external =
|
|
714
|
+
!!options.allowExternal ||
|
|
715
|
+
!!options.allowAgents ||
|
|
716
|
+
plugins.externalCount > 0 ||
|
|
717
|
+
registry.list().some((t) => t.name.startsWith("p_"));
|
|
718
|
+
},
|
|
613
719
|
/** Persist an MCP toggle in its defining layer, then safely update this process. */
|
|
614
720
|
async setMcpEnabled(id, enabled, connect = false) {
|
|
615
721
|
if (options.readOnly)
|
|
@@ -688,6 +794,7 @@ export async function createApplication(options = {}) {
|
|
|
688
794
|
providerError = undefined;
|
|
689
795
|
providerInfo = { id, profile, persisted: true, profileName };
|
|
690
796
|
models.clear();
|
|
797
|
+
modelsLoading = undefined;
|
|
691
798
|
catalogCache = undefined;
|
|
692
799
|
}
|
|
693
800
|
catch (error) {
|
package/dist/config.d.ts
CHANGED
|
@@ -85,6 +85,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
|
|
|
85
85
|
enabled: boolean;
|
|
86
86
|
bearerTokenEnv?: string | undefined;
|
|
87
87
|
}>>]>>>;
|
|
88
|
+
allow: z.ZodOptional<z.ZodBoolean>;
|
|
88
89
|
}, z.core.$strict>>;
|
|
89
90
|
mcpServers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
90
91
|
transport: z.ZodLiteral<"stdio">;
|
|
@@ -223,6 +224,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
|
|
|
223
224
|
maxTokens?: number | undefined;
|
|
224
225
|
};
|
|
225
226
|
mcp: {
|
|
227
|
+
allow?: boolean | undefined;
|
|
226
228
|
servers: {
|
|
227
229
|
[x: string]: {
|
|
228
230
|
transport: "stdio";
|
|
@@ -294,6 +296,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
|
|
|
294
296
|
bearerTokenEnv?: string | undefined;
|
|
295
297
|
transport: "http";
|
|
296
298
|
}>;
|
|
299
|
+
allow?: boolean | undefined;
|
|
297
300
|
};
|
|
298
301
|
mcpServers?: Record<string, {
|
|
299
302
|
transport: "stdio";
|
|
@@ -376,7 +379,11 @@ export interface ConfigLoadResult {
|
|
|
376
379
|
config: LoadedConfig;
|
|
377
380
|
provenance: ConfigProvenance;
|
|
378
381
|
}
|
|
379
|
-
/**
|
|
382
|
+
/**
|
|
383
|
+
* Whether legacy provider selection should take priority over a saved plugin profile for this
|
|
384
|
+
* run: an explicit endpoint override, or a trusted project/explicit layer whose parsed root
|
|
385
|
+
* `provider.model` is actually usable (non-empty and not the `alisio setup` placeholder).
|
|
386
|
+
*/
|
|
380
387
|
export declare function overridesSavedProviderProfile(provenance: ConfigProvenance, hasEndpointOverride: boolean): boolean;
|
|
381
388
|
export declare const configHome: () => string;
|
|
382
389
|
export declare const stateHome: () => string;
|
|
@@ -405,6 +412,10 @@ export declare function setMcpServerEnabled(input: {
|
|
|
405
412
|
name: string;
|
|
406
413
|
enabled: boolean;
|
|
407
414
|
}): Promise<string>;
|
|
415
|
+
/** Atomically sets or clears the global `mcp.allow` consent preference in the user config file. */
|
|
416
|
+
export declare function setGlobalMcpAllow(input: {
|
|
417
|
+
allow: boolean;
|
|
418
|
+
}): Promise<string>;
|
|
408
419
|
/** Atomically updates one project plugin override while preserving every unrelated JSON field. */
|
|
409
420
|
export declare function setProjectPluginEnabled(input: {
|
|
410
421
|
workspace: string;
|
package/dist/config.js
CHANGED
|
@@ -84,7 +84,14 @@ const configObjectSchema = z
|
|
|
84
84
|
/** Project-local enable/disable overrides for effective non-plugin skills. */
|
|
85
85
|
skillOverrides: z.record(z.string(), z.object({ enabled: z.boolean() }).strict()).default({}),
|
|
86
86
|
mcp: z
|
|
87
|
-
.object({
|
|
87
|
+
.object({
|
|
88
|
+
servers: serversSchema(compatibleServerSchema).default({}),
|
|
89
|
+
/**
|
|
90
|
+
* Global-only user preference: grants MCP process/network consent across sessions.
|
|
91
|
+
* Only the user (global) configuration layer is consulted; a project value is ignored.
|
|
92
|
+
*/
|
|
93
|
+
allow: z.boolean().optional(),
|
|
94
|
+
})
|
|
88
95
|
.strict()
|
|
89
96
|
.default({ servers: {} }),
|
|
90
97
|
/** Compatibility with the common MCP client configuration shape. */
|
|
@@ -172,9 +179,24 @@ const configObjectSchema = z
|
|
|
172
179
|
});
|
|
173
180
|
export const configSchema = configObjectSchema.transform(({ mcpServers, ...config }) => ({
|
|
174
181
|
...config,
|
|
175
|
-
mcp: { servers: { ...config.mcp.servers, ...mcpServers } },
|
|
182
|
+
mcp: { ...config.mcp, servers: { ...config.mcp.servers, ...mcpServers } },
|
|
176
183
|
}));
|
|
177
|
-
/**
|
|
184
|
+
/** The `alisio setup` scaffold writes this placeholder; a model matching it is not usable. */
|
|
185
|
+
const LEGACY_PROVIDER_MODEL_PLACEHOLDER = "YOUR_MODEL_ID";
|
|
186
|
+
/**
|
|
187
|
+
* A legacy root `provider` is usable only when its parsed model is non-empty and not the
|
|
188
|
+
* `alisio setup` placeholder, so a scaffolded or empty project config never defeats a saved
|
|
189
|
+
* `/connect` profile.
|
|
190
|
+
*/
|
|
191
|
+
function isUsableLegacyModel(model) {
|
|
192
|
+
const normalized = model.trim();
|
|
193
|
+
return normalized !== "" && normalized !== LEGACY_PROVIDER_MODEL_PLACEHOLDER;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Whether legacy provider selection should take priority over a saved plugin profile for this
|
|
197
|
+
* run: an explicit endpoint override, or a trusted project/explicit layer whose parsed root
|
|
198
|
+
* `provider.model` is actually usable (non-empty and not the `alisio setup` placeholder).
|
|
199
|
+
*/
|
|
178
200
|
export function overridesSavedProviderProfile(provenance, hasEndpointOverride) {
|
|
179
201
|
return hasEndpointOverride || !!provenance.selectedLayer?.hasLegacyProvider;
|
|
180
202
|
}
|
|
@@ -233,10 +255,12 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
|
|
|
233
255
|
global.config.skillOverrides = {};
|
|
234
256
|
let config = global.config;
|
|
235
257
|
let mcpSources = global.sources;
|
|
236
|
-
|
|
258
|
+
// Parsed `provider.model` of the selected layer itself (before env/CLI overrides), used to
|
|
259
|
+
// decide whether its legacy root `provider` is usable against a saved `/connect` profile.
|
|
260
|
+
let selectedLegacyModel = "";
|
|
237
261
|
if (selectedFile && selectedFile !== globalFile) {
|
|
238
262
|
const selected = await parseLayer(selectedFile, options.file ? "explicit" : "project");
|
|
239
|
-
|
|
263
|
+
selectedLegacyModel = selected.config.provider.model;
|
|
240
264
|
const overlaid = { ...config };
|
|
241
265
|
for (const key of selected.keys) {
|
|
242
266
|
if (key === "mcp" || key === "mcpServers")
|
|
@@ -245,14 +269,17 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
|
|
|
245
269
|
overlaid[key] = selected.config[key];
|
|
246
270
|
}
|
|
247
271
|
if (selected.keys.has("mcp") || selected.keys.has("mcpServers"))
|
|
272
|
+
// Only servers merge upward; `mcp.allow` is a global/user preference and is deliberately
|
|
273
|
+
// dropped from the selected layer so a project can never grant itself network consent.
|
|
248
274
|
overlaid.mcp = {
|
|
275
|
+
...config.mcp,
|
|
249
276
|
servers: { ...config.mcp.servers, ...selected.config.mcp.servers },
|
|
250
277
|
};
|
|
251
278
|
mcpSources = { ...mcpSources, ...selected.sources };
|
|
252
279
|
config = overlaid;
|
|
253
280
|
}
|
|
254
281
|
else if (selectedFile) {
|
|
255
|
-
|
|
282
|
+
selectedLegacyModel = global.config.provider.model;
|
|
256
283
|
}
|
|
257
284
|
config.provider = providerSchema.parse({
|
|
258
285
|
...config.provider,
|
|
@@ -273,7 +300,7 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
|
|
|
273
300
|
? {
|
|
274
301
|
selectedLayer: {
|
|
275
302
|
kind: options.file ? "explicit" : "project",
|
|
276
|
-
hasLegacyProvider:
|
|
303
|
+
hasLegacyProvider: isUsableLegacyModel(selectedLegacyModel),
|
|
277
304
|
},
|
|
278
305
|
}
|
|
279
306
|
: {}),
|
|
@@ -326,6 +353,51 @@ export async function setMcpServerEnabled(input) {
|
|
|
326
353
|
}
|
|
327
354
|
return file;
|
|
328
355
|
}
|
|
356
|
+
/** Atomically sets or clears the global `mcp.allow` consent preference in the user config file. */
|
|
357
|
+
export async function setGlobalMcpAllow(input) {
|
|
358
|
+
const file = join(configHome(), "config.json");
|
|
359
|
+
let raw = {};
|
|
360
|
+
if (await exists(file)) {
|
|
361
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
362
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
363
|
+
throw new Error("Global Alisio configuration must be a JSON object");
|
|
364
|
+
raw = parsed;
|
|
365
|
+
}
|
|
366
|
+
if (input.allow) {
|
|
367
|
+
const current = raw.mcp && typeof raw.mcp === "object" && !Array.isArray(raw.mcp)
|
|
368
|
+
? raw.mcp
|
|
369
|
+
: {};
|
|
370
|
+
raw.mcp = { ...current, allow: true };
|
|
371
|
+
}
|
|
372
|
+
else if (raw.mcp && typeof raw.mcp === "object" && !Array.isArray(raw.mcp)) {
|
|
373
|
+
const mcp = { ...raw.mcp };
|
|
374
|
+
delete mcp.allow;
|
|
375
|
+
if (Object.keys(mcp).length)
|
|
376
|
+
raw.mcp = mcp;
|
|
377
|
+
else
|
|
378
|
+
delete raw.mcp;
|
|
379
|
+
}
|
|
380
|
+
raw.schemaVersion ??= 1;
|
|
381
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
382
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
383
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
384
|
+
try {
|
|
385
|
+
await handle.writeFile(`${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
386
|
+
await handle.sync();
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
await handle.close();
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
await rename(temporary, file);
|
|
393
|
+
await chmod(file, 0o600);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
await unlink(temporary).catch(() => { });
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
return file;
|
|
400
|
+
}
|
|
329
401
|
/** Atomically updates one project plugin override while preserving every unrelated JSON field. */
|
|
330
402
|
export async function setProjectPluginEnabled(input) {
|
|
331
403
|
const file = join(input.workspace, ".alisio", "config.json");
|
package/dist/core/runner.d.ts
CHANGED
|
@@ -108,14 +108,16 @@ export declare class AgentRunner {
|
|
|
108
108
|
/** Estimated tokens the next request of a session would send. */
|
|
109
109
|
estimateContext(sessionId: string): Promise<number>;
|
|
110
110
|
/**
|
|
111
|
-
* The effective context budget
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* compaction triggers, so the bar turns red exactly where the engine
|
|
111
|
+
* The effective context budget the TUI context bar displays. The model window is the total
|
|
112
|
+
* when known (and not beyond `MAX_TRUSTED_WINDOW`); otherwise the bar reports an honest
|
|
113
|
+
* unknown (`basis: "unknown"`, no fabricated total). `compactionAt` is the percentage of
|
|
114
|
+
* `total` where auto-compaction triggers, so the bar turns red exactly where the engine
|
|
115
|
+
* compacts. The engine's own auto-compaction guardrail still falls back to the char budget
|
|
116
|
+
* internally (see `shouldCompactContext`) — that fallback is never shown as a fake total.
|
|
115
117
|
*/
|
|
116
118
|
contextBudget(model: string): {
|
|
117
|
-
total
|
|
118
|
-
basis: "window" | "
|
|
119
|
+
total?: number;
|
|
120
|
+
basis: "window" | "unknown";
|
|
119
121
|
compactionAt: number;
|
|
120
122
|
};
|
|
121
123
|
/** Change the model used by subsequent turns of a session; recorded in the store. */
|
package/dist/core/runner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { textResult, } from "@alisio/sdk";
|
|
2
|
-
import { checkpointInstructions,
|
|
2
|
+
import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
|
|
3
3
|
/**
|
|
4
4
|
* Default cumulative token budget for one run: several context windows (each turn re-sends the
|
|
5
5
|
* context), clamped; 1M tokens when the window is unknown.
|
|
@@ -112,19 +112,22 @@ export class AgentRunner {
|
|
|
112
112
|
estimateTokens(this.toolsText(this.availableTools())));
|
|
113
113
|
}
|
|
114
114
|
/**
|
|
115
|
-
* The effective context budget
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* compaction triggers, so the bar turns red exactly where the engine
|
|
115
|
+
* The effective context budget the TUI context bar displays. The model window is the total
|
|
116
|
+
* when known (and not beyond `MAX_TRUSTED_WINDOW`); otherwise the bar reports an honest
|
|
117
|
+
* unknown (`basis: "unknown"`, no fabricated total). `compactionAt` is the percentage of
|
|
118
|
+
* `total` where auto-compaction triggers, so the bar turns red exactly where the engine
|
|
119
|
+
* compacts. The engine's own auto-compaction guardrail still falls back to the char budget
|
|
120
|
+
* internally (see `shouldCompactContext`) — that fallback is never shown as a fake total.
|
|
119
121
|
*/
|
|
120
122
|
contextBudget(model) {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
: 100,
|
|
127
|
-
|
|
123
|
+
const window = this.options.contextWindow?.(model);
|
|
124
|
+
if (window !== undefined && window > 0 && window <= MAX_TRUSTED_WINDOW)
|
|
125
|
+
return {
|
|
126
|
+
total: window,
|
|
127
|
+
basis: "window",
|
|
128
|
+
compactionAt: Math.round((this.options.compaction?.threshold ?? 0.85) * 100),
|
|
129
|
+
};
|
|
130
|
+
return { total: undefined, basis: "unknown", compactionAt: 100 };
|
|
128
131
|
}
|
|
129
132
|
/** Change the model used by subsequent turns of a session; recorded in the store. */
|
|
130
133
|
setModel(sessionId, model) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @alisio/core public API: embed the Alisio agent programmatically. */
|
|
2
2
|
export * from "@alisio/sdk";
|
|
3
3
|
export { type AppOptions, type BuiltinContext, type BuiltinPlugin, createApplication, enabledBuiltins, type PluginCatalogEntry, type PluginCatalogStatus, } from "./application.ts";
|
|
4
|
-
export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, overridesSavedProviderProfile, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.ts";
|
|
4
|
+
export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, overridesSavedProviderProfile, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.ts";
|
|
5
5
|
export { type ContextBudget, checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.ts";
|
|
6
6
|
export type { ApprovalDecision, ApprovalHandler, ApprovalRequest, ContextSource, HookFailure, Policy, RunnerExtensions, Session, SessionStore, } from "./core/contracts.ts";
|
|
7
7
|
export { ToolRegistry } from "./core/registry.ts";
|
|
@@ -10,6 +10,7 @@ export { type ExtensionConflict, ExtensionRegistry } from "./extensions/registry
|
|
|
10
10
|
export { HerdrBridge } from "./integrations/herdr.ts";
|
|
11
11
|
export { McpConnector, type McpServerInfo, type McpStatus, type McpToolInfo, } from "./mcp/connector.ts";
|
|
12
12
|
export { discoverPlugins, PluginHost, type PluginHostOptions, pluginPrefix, } from "./plugins/host.ts";
|
|
13
|
+
export { type CliInstallOptions, cliInstall, INSTALL_TIMEOUT_MS, type InstallPluginInput, type InstallPluginResult, type InstallRunner, installedNpmPlugins, installPlugin, npmInstallTarget, type PluginSpec, parsePluginSpec, pluginTrustRemark, printInstallResult, registerPluginInstallTool, sanitizeNpmError, } from "./plugins/install.ts";
|
|
13
14
|
export { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.ts";
|
|
14
15
|
export { availableProviderModels, type ProviderModelCatalog, resolveProviderModel, } from "./providers/routing.ts";
|
|
15
16
|
export { type ProviderProfile, type ProviderSettings, ProviderSettingsStore, } from "./providers/settings.ts";
|
|
@@ -19,7 +20,7 @@ export { type Skill, type SkillCatalogEntry, type SkillRoot, type SkillScope, Sk
|
|
|
19
20
|
export { exists, fileSize, readHead, readJson, readText, which } from "./runtime/fs.ts";
|
|
20
21
|
export { defaultGlobalRoots, isPathSpec, PLUGIN_KEYWORD, resolvePluginSpec, } from "./runtime/modules.ts";
|
|
21
22
|
export { findWorkspace, safePath } from "./runtime/paths.ts";
|
|
22
|
-
export { type ProcessResult, runProcess } from "./runtime/process.ts";
|
|
23
|
+
export { isMissingCommand, type ProcessResult, RIPGREP_INSTALL_HINT, runProcess, } from "./runtime/process.ts";
|
|
23
24
|
export { isSqliteExperimentalWarning, openDatabase } from "./runtime/sqlite.ts";
|
|
24
25
|
export { SQLiteStore } from "./runtime/store.ts";
|
|
25
26
|
export { ChildSessions, type ChildSessionsOptions } from "./sessions/children.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @alisio/core public API: embed the Alisio agent programmatically. */
|
|
2
2
|
export * from "@alisio/sdk";
|
|
3
3
|
export { createApplication, enabledBuiltins, } from "./application.js";
|
|
4
|
-
export { configFile, configHome, configSchema, loadConfig, loadConfigWithProvenance, overridesSavedProviderProfile, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
|
|
4
|
+
export { configFile, configHome, configSchema, loadConfig, loadConfigWithProvenance, overridesSavedProviderProfile, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
|
|
5
5
|
export { checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.js";
|
|
6
6
|
export { ToolRegistry } from "./core/registry.js";
|
|
7
7
|
export { AgentRunner, defaultTokenBudget, } from "./core/runner.js";
|
|
@@ -9,6 +9,7 @@ export { ExtensionRegistry } from "./extensions/registry.js";
|
|
|
9
9
|
export { HerdrBridge } from "./integrations/herdr.js";
|
|
10
10
|
export { McpConnector, } from "./mcp/connector.js";
|
|
11
11
|
export { discoverPlugins, PluginHost, pluginPrefix, } from "./plugins/host.js";
|
|
12
|
+
export { cliInstall, INSTALL_TIMEOUT_MS, installedNpmPlugins, installPlugin, npmInstallTarget, parsePluginSpec, pluginTrustRemark, printInstallResult, registerPluginInstallTool, sanitizeNpmError, } from "./plugins/install.js";
|
|
12
13
|
export { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.js";
|
|
13
14
|
export { availableProviderModels, resolveProviderModel, } from "./providers/routing.js";
|
|
14
15
|
export { ProviderSettingsStore, } from "./providers/settings.js";
|
|
@@ -18,7 +19,7 @@ export { Skills, skillRoots, } from "./resources/skills.js";
|
|
|
18
19
|
export { exists, fileSize, readHead, readJson, readText, which } from "./runtime/fs.js";
|
|
19
20
|
export { defaultGlobalRoots, isPathSpec, PLUGIN_KEYWORD, resolvePluginSpec, } from "./runtime/modules.js";
|
|
20
21
|
export { findWorkspace, safePath } from "./runtime/paths.js";
|
|
21
|
-
export { runProcess } from "./runtime/process.js";
|
|
22
|
+
export { isMissingCommand, RIPGREP_INSTALL_HINT, runProcess, } from "./runtime/process.js";
|
|
22
23
|
export { isSqliteExperimentalWarning, openDatabase } from "./runtime/sqlite.js";
|
|
23
24
|
export { SQLiteStore } from "./runtime/store.js";
|
|
24
25
|
export { ChildSessions } from "./sessions/children.js";
|
package/dist/mcp/connector.d.ts
CHANGED
|
@@ -48,6 +48,8 @@ export declare class McpConnector {
|
|
|
48
48
|
constructor(servers: Record<string, ServerConfig>, registry: ToolRegistry, workspace: string, availability?: "allowed" | "disabled" | "read-only", sources?: Record<string, McpServerSource>);
|
|
49
49
|
register(): void;
|
|
50
50
|
grantRuntimePermission(): void;
|
|
51
|
+
/** Drop the runtime grant: disconnect every server and unregister the bridge (used on revoke). */
|
|
52
|
+
revokeRuntimePermission(): Promise<void>;
|
|
51
53
|
runtimePermission(): McpServerInfo["runtimePermission"];
|
|
52
54
|
private assertAvailable;
|
|
53
55
|
private client;
|
package/dist/mcp/connector.js
CHANGED
|
@@ -3,6 +3,7 @@ import { textResult } from "@alisio/sdk";
|
|
|
3
3
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
4
4
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
5
5
|
import { objectSchema } from "../tools/standard.js";
|
|
6
|
+
import { loadVersion } from "../version.js";
|
|
6
7
|
const TOOL_NAME_LIMIT = 64;
|
|
7
8
|
const semanticSegment = (value, fallback) => value
|
|
8
9
|
.normalize("NFKD")
|
|
@@ -138,6 +139,13 @@ export class McpConnector {
|
|
|
138
139
|
this.availability = "allowed";
|
|
139
140
|
this.register();
|
|
140
141
|
}
|
|
142
|
+
/** Drop the runtime grant: disconnect every server and unregister the bridge (used on revoke). */
|
|
143
|
+
async revokeRuntimePermission() {
|
|
144
|
+
await Promise.all([...this.clients.keys()].map((name) => this.disconnect(name)));
|
|
145
|
+
for (const undo of this.bridgeUndo.splice(0).reverse())
|
|
146
|
+
undo();
|
|
147
|
+
this.availability = "disabled";
|
|
148
|
+
}
|
|
141
149
|
runtimePermission() {
|
|
142
150
|
return this.availability === "allowed"
|
|
143
151
|
? "granted"
|
|
@@ -212,7 +220,7 @@ export class McpConnector {
|
|
|
212
220
|
}
|
|
213
221
|
this.statuses.set(name, "connecting");
|
|
214
222
|
this.diagnostics.delete(name);
|
|
215
|
-
const client = new Client({ name: "alisio", version:
|
|
223
|
+
const client = new Client({ name: "alisio", version: loadVersion(import.meta.url) }, { capabilities: {} });
|
|
216
224
|
const undo = [];
|
|
217
225
|
const generation = Symbol(name);
|
|
218
226
|
const transport = config.transport === "stdio"
|
package/dist/plugins/host.js
CHANGED
|
@@ -566,7 +566,13 @@ export async function discoverPlugins(root) {
|
|
|
566
566
|
throw e;
|
|
567
567
|
}
|
|
568
568
|
return entries
|
|
569
|
-
.filter(
|
|
569
|
+
.filter(
|
|
570
|
+
// A root may hold an `npm install --prefix` layout (`node_modules`, package.json/lock);
|
|
571
|
+
// npm packages there are resolved through the configured `plugins` array instead.
|
|
572
|
+
(e) => e.name !== "node_modules" &&
|
|
573
|
+
e.name !== "package.json" &&
|
|
574
|
+
e.name !== "package-lock.json" &&
|
|
575
|
+
((e.isFile() && /\.[cm]?[jt]s$/.test(e.name)) || e.isDirectory()))
|
|
570
576
|
.map((e) => join(root, e.name))
|
|
571
577
|
.sort();
|
|
572
578
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ToolRegistry } from "../core/registry.ts";
|
|
2
|
+
import { type ProcessResult } from "../runtime/process.ts";
|
|
3
|
+
export interface PluginSpec {
|
|
4
|
+
name: string;
|
|
5
|
+
version?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Validates an install spec without touching the network: `npm:<package>[@<version>]` (canonical),
|
|
9
|
+
* a bare package name (same resolution), and nothing else. Rejects shell metacharacters, `..` /
|
|
10
|
+
* absolute paths, and unknown prefixes (`registry:`, `git:`, `file:`, URLs) with a clear error.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parsePluginSpec(spec: string): PluginSpec;
|
|
13
|
+
/** The npm argument for a spec: `name`, `name@<version>` or `name@latest` for `--update`. */
|
|
14
|
+
export declare function npmInstallTarget(name: string, version?: string, update?: boolean): string;
|
|
15
|
+
/**
|
|
16
|
+
* Injectable process runner so tests can substitute a fake `npm` without a network or a PATH shim.
|
|
17
|
+
* The default runs the real npm with a generous timeout and bounded output.
|
|
18
|
+
*/
|
|
19
|
+
export type InstallRunner = (command: string, args: string[], options: {
|
|
20
|
+
cwd: string;
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
env?: Record<string, string>;
|
|
23
|
+
}) => Promise<ProcessResult>;
|
|
24
|
+
export declare const INSTALL_TIMEOUT_MS = 300000;
|
|
25
|
+
export interface InstallPluginInput {
|
|
26
|
+
/** `npm:<package>[@<version>]` or a bare package name. */
|
|
27
|
+
spec: string;
|
|
28
|
+
/** Defaults to `configHome()` (ALISIO_CONFIG_HOME, else ~/.config/alisio). */
|
|
29
|
+
configHome?: string;
|
|
30
|
+
/** Refresh an already-installed package to `@latest`, keeping the npm name in the config. */
|
|
31
|
+
update?: boolean;
|
|
32
|
+
/** Refuse immediately; loading and installing executable plugins is disabled under --read-only. */
|
|
33
|
+
readOnly?: boolean;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
/** Injected installer for tests (fake npm); defaults to the real npm. */
|
|
36
|
+
runner?: InstallRunner;
|
|
37
|
+
}
|
|
38
|
+
export interface InstallPluginResult {
|
|
39
|
+
packageName: string;
|
|
40
|
+
/** Version read back from the installed package.json, or "unknown". */
|
|
41
|
+
installedVersion: string;
|
|
42
|
+
/** `<configHome>/plugins/node_modules/<name>` (a directory). */
|
|
43
|
+
path: string;
|
|
44
|
+
/** The npm name persisted in the global configuration `plugins` array. */
|
|
45
|
+
configEntry: string;
|
|
46
|
+
/** The global configuration file that received the entry. */
|
|
47
|
+
configFile: string;
|
|
48
|
+
pluginsDir: string;
|
|
49
|
+
/** Already present in the global plugins directory before this call (no npm run performed). */
|
|
50
|
+
alreadyInstalled: boolean;
|
|
51
|
+
/** Refreshed to `@latest` by this call. */
|
|
52
|
+
updated: boolean;
|
|
53
|
+
/** Plain-text remark about where the plugin loads and how project trust applies. */
|
|
54
|
+
trustRemark: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Redacts probable secrets from npm output (auth tokens, JWTs, bearer credentials, userinfo in
|
|
58
|
+
* URLs) and caps the size, so a failing install never echoes credentials to the user.
|
|
59
|
+
*/
|
|
60
|
+
export declare function sanitizeNpmError(text: string, maxLength?: number): string;
|
|
61
|
+
/** The shared install routine used by both the CLI command and the `plugin_install` tool. */
|
|
62
|
+
export declare function installPlugin(input: InstallPluginInput): Promise<InstallPluginResult>;
|
|
63
|
+
/** Final remark about loading and project trust, printed by the CLI and returned by the tool. */
|
|
64
|
+
export declare function pluginTrustRemark(name: string): string;
|
|
65
|
+
export interface CliInstallOptions {
|
|
66
|
+
spec: string;
|
|
67
|
+
configHome?: string;
|
|
68
|
+
/** `--yes`/`--trust-plugin`: skip the interactive pre-install confirmation. */
|
|
69
|
+
yes?: boolean;
|
|
70
|
+
update?: boolean;
|
|
71
|
+
readOnly?: boolean;
|
|
72
|
+
/** True when the CLI can prompt (interactive TTY, not --json). */
|
|
73
|
+
interactive?: boolean;
|
|
74
|
+
/** Print the result as JSON instead of human lines. */
|
|
75
|
+
json?: boolean;
|
|
76
|
+
signal?: AbortSignal;
|
|
77
|
+
runner?: InstallRunner;
|
|
78
|
+
/** Injectable confirmation for the interactive path (tests); defaults to a readline prompt. */
|
|
79
|
+
confirm?: () => Promise<boolean>;
|
|
80
|
+
}
|
|
81
|
+
export declare function cliInstall(options: CliInstallOptions): Promise<InstallPluginResult>;
|
|
82
|
+
export declare function printInstallResult(result: InstallPluginResult, options?: {
|
|
83
|
+
json?: boolean;
|
|
84
|
+
}): void;
|
|
85
|
+
/**
|
|
86
|
+
* Registers the host-owned `plugin_install` tool (agent capability). It has process effect so it
|
|
87
|
+
* goes through the existing write/process/external permission gate (TUI approval or
|
|
88
|
+
* `--allow-process`; never available under `--read-only`, where it is not registered at all).
|
|
89
|
+
*/
|
|
90
|
+
export declare function registerPluginInstallTool(registry: ToolRegistry, options?: {
|
|
91
|
+
readOnly?: boolean;
|
|
92
|
+
configHome?: string;
|
|
93
|
+
runner?: InstallRunner;
|
|
94
|
+
}): void;
|
|
95
|
+
/**
|
|
96
|
+
* Lists npm packages installed under `<root>/node_modules` that declare the `alisio-plugin`
|
|
97
|
+
* keyword — the same packages `plugins`-array entries resolve to. Used by `alisio plugins list`
|
|
98
|
+
* so an installed package shows up alongside file/directory plugins.
|
|
99
|
+
*/
|
|
100
|
+
export declare function installedNpmPlugins(root: string): Promise<string[]>;
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installer for npm plugin packages: `alisio install npm:<package>[@<version>]`.
|
|
3
|
+
*
|
|
4
|
+
* One shared routine (`installPlugin`) backs both the CLI command (`cliInstall`) and the
|
|
5
|
+
* host-owned `plugin_install` tool for the agent, so the two can never drift apart. The install
|
|
6
|
+
* target is the GLOBAL plugins directory `<configHome>/plugins` (default `~/.config/alisio/plugins`),
|
|
7
|
+
* installed with `npm install --prefix`; the package NAME is persisted in the global configuration
|
|
8
|
+
* `plugins` array (never the resolved filesystem path), keeping entries portable. Loading the plugin
|
|
9
|
+
* still follows the existing executable-plugin policy: it runs in-process wherever it is loaded and
|
|
10
|
+
* is disabled entirely under `--read-only`.
|
|
11
|
+
*/
|
|
12
|
+
import { chmod, mkdir, open, readdir, rename, unlink } from "node:fs/promises";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { textResult } from "@alisio/sdk";
|
|
15
|
+
import { configHome } from "../config.js";
|
|
16
|
+
import { readJson } from "../runtime/fs.js";
|
|
17
|
+
import { PLUGIN_KEYWORD } from "../runtime/modules.js";
|
|
18
|
+
import { runProcess } from "../runtime/process.js";
|
|
19
|
+
/** npm package name: scope (optional) followed by the name; both start with an alphanumeric. */
|
|
20
|
+
const PACKAGE_PART = "[A-Za-z0-9][A-Za-z0-9._-]*";
|
|
21
|
+
const NAME_RE = new RegExp(`^(?:@${PACKAGE_PART}\\/)?${PACKAGE_PART}$`);
|
|
22
|
+
/** Version specifier: npm's `@<version>` part, restricted to the same safe character set. */
|
|
23
|
+
const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
24
|
+
/**
|
|
25
|
+
* Validates an install spec without touching the network: `npm:<package>[@<version>]` (canonical),
|
|
26
|
+
* a bare package name (same resolution), and nothing else. Rejects shell metacharacters, `..` /
|
|
27
|
+
* absolute paths, and unknown prefixes (`registry:`, `git:`, `file:`, URLs) with a clear error.
|
|
28
|
+
*/
|
|
29
|
+
export function parsePluginSpec(spec) {
|
|
30
|
+
if (!spec || !spec.trim())
|
|
31
|
+
throw new Error("Plugin spec is empty: use npm:<package>[@<version>] or a bare package name, for example npm:plugin-openrouter");
|
|
32
|
+
const trimmed = spec.trim();
|
|
33
|
+
let rest = trimmed;
|
|
34
|
+
if (trimmed.startsWith("npm:"))
|
|
35
|
+
rest = trimmed.slice(4);
|
|
36
|
+
else {
|
|
37
|
+
const colon = trimmed.indexOf(":");
|
|
38
|
+
if (colon >= 0)
|
|
39
|
+
throw new Error(`Unsupported plugin spec prefix "${trimmed.slice(0, colon)}:": only npm:<package>[@<version>] ` +
|
|
40
|
+
`or a bare package name is supported (registry:/git:/file:/http: specs are not)` +
|
|
41
|
+
(trimmed.startsWith("registry:")
|
|
42
|
+
? "; pass the package name directly instead of a registry URL"
|
|
43
|
+
: ""));
|
|
44
|
+
}
|
|
45
|
+
if (!rest)
|
|
46
|
+
throw new Error(`Plugin spec "${spec}" has no package name`);
|
|
47
|
+
const at = rest.lastIndexOf("@");
|
|
48
|
+
let name = rest;
|
|
49
|
+
let version;
|
|
50
|
+
if (at > 0) {
|
|
51
|
+
name = rest.slice(0, at);
|
|
52
|
+
version = rest.slice(at + 1);
|
|
53
|
+
}
|
|
54
|
+
if (name.length > 214)
|
|
55
|
+
throw new Error(`Invalid plugin spec "${spec}": package name is too long`);
|
|
56
|
+
if (!NAME_RE.test(name) || name.includes(".."))
|
|
57
|
+
throw new Error(`Invalid plugin package name "${name}" in spec "${spec}": allowed characters are letters, ` +
|
|
58
|
+
`digits, dot, underscore and dash, with "/" only for a scoped name and "@" only for a version; ` +
|
|
59
|
+
`names may not start with "." or "_" and may not contain ".."`);
|
|
60
|
+
if (version !== undefined && !VERSION_RE.test(version))
|
|
61
|
+
throw new Error(`Invalid plugin version "${version}" in spec "${spec}": versions may contain only letters, ` +
|
|
62
|
+
`digits, dot, underscore and dash (for example 1.2.3, 1.2.3-beta.1 or latest)`);
|
|
63
|
+
return { name, version };
|
|
64
|
+
}
|
|
65
|
+
/** The npm argument for a spec: `name`, `name@<version>` or `name@latest` for `--update`. */
|
|
66
|
+
export function npmInstallTarget(name, version, update = false) {
|
|
67
|
+
return update ? `${name}@latest` : version ? `${name}@${version}` : name;
|
|
68
|
+
}
|
|
69
|
+
export const INSTALL_TIMEOUT_MS = 300_000;
|
|
70
|
+
const defaultRunner = (command, args, options) => runProcess(command, args, { ...options, timeoutMs: INSTALL_TIMEOUT_MS, maxBytes: 64_000 });
|
|
71
|
+
/**
|
|
72
|
+
* Redacts probable secrets from npm output (auth tokens, JWTs, bearer credentials, userinfo in
|
|
73
|
+
* URLs) and caps the size, so a failing install never echoes credentials to the user.
|
|
74
|
+
*/
|
|
75
|
+
export function sanitizeNpmError(text, maxLength = 4000) {
|
|
76
|
+
const MAX = Math.max(1, maxLength);
|
|
77
|
+
let cleaned = text.replace(/(eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]+|\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*\b|\b(?:npm_?authToken|_authToken|NODE_AUTH_TOKEN|NPM_TOKEN|authToken|password|secret)\b\s*(?:=|:)\s*\S+|\b(?:npm_?authToken|_authToken|NODE_AUTH_TOKEN|NPM_TOKEN|authToken|password|secret)\b\s+\S+)/gi, "[redacted]");
|
|
78
|
+
cleaned = cleaned.replace(/([a-z][a-z0-9+.-]*):\/\/[^@\s/]+@/gi, "$1://[redacted]@");
|
|
79
|
+
if (cleaned.length > MAX)
|
|
80
|
+
cleaned = `${cleaned.slice(0, MAX)}\n…[npm output truncated]`;
|
|
81
|
+
return cleaned;
|
|
82
|
+
}
|
|
83
|
+
function npmInstallError(name, version, update, pluginsDir, result) {
|
|
84
|
+
const detail = sanitizeNpmError([result.stdout, result.stderr].filter(Boolean).join("\n"));
|
|
85
|
+
const retry = `npm install --prefix ${pluginsDir} ${npmInstallTarget(name, version, update)}`;
|
|
86
|
+
return new Error(`npm install failed for plugin "${name}" (exit ${result.exitCode}).\n${detail || "no output"}\n` +
|
|
87
|
+
`Retry with: ${retry}\n(or: alisio install npm:${npmInstallTarget(name, version)})`);
|
|
88
|
+
}
|
|
89
|
+
/** Atomically adds `packageName` to the global configuration `plugins` array, preserving all other fields. */
|
|
90
|
+
async function persistGlobalPluginEntry(configHomeDir, packageName) {
|
|
91
|
+
const file = join(configHomeDir, "config.json");
|
|
92
|
+
const existing = await readJson(file);
|
|
93
|
+
let raw;
|
|
94
|
+
if (existing === undefined)
|
|
95
|
+
raw = {};
|
|
96
|
+
else if (typeof existing === "object" && !Array.isArray(existing))
|
|
97
|
+
raw = existing;
|
|
98
|
+
else
|
|
99
|
+
throw new Error(`Global Alisio configuration must be a JSON object: ${file}`);
|
|
100
|
+
const current = Array.isArray(raw.plugins) ? raw.plugins : [];
|
|
101
|
+
if (!current.includes(packageName))
|
|
102
|
+
raw.plugins = [...current, packageName];
|
|
103
|
+
raw.schemaVersion ??= 1;
|
|
104
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
105
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
106
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
107
|
+
try {
|
|
108
|
+
await handle.writeFile(`${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
109
|
+
await handle.sync();
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await handle.close();
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await rename(temporary, file);
|
|
116
|
+
await chmod(file, 0o600);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
await unlink(temporary).catch(() => { });
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
return file;
|
|
123
|
+
}
|
|
124
|
+
/** The shared install routine used by both the CLI command and the `plugin_install` tool. */
|
|
125
|
+
export async function installPlugin(input) {
|
|
126
|
+
const parsed = parsePluginSpec(input.spec);
|
|
127
|
+
if (input.readOnly)
|
|
128
|
+
throw new Error(`Installing npm plugin "${parsed.name}" is unavailable under --read-only: installation runs npm and writes your global configuration. Run without --read-only.`);
|
|
129
|
+
const home = resolve(input.configHome ?? configHome());
|
|
130
|
+
const pluginsDir = join(home, "plugins");
|
|
131
|
+
// npm creates the prefix directory itself, but the subprocess cwd must already exist for spawn.
|
|
132
|
+
await mkdir(pluginsDir, { recursive: true, mode: 0o700 });
|
|
133
|
+
const packageDir = join(pluginsDir, "node_modules", parsed.name);
|
|
134
|
+
const installedManifest = (await readJson(join(packageDir, "package.json")));
|
|
135
|
+
const alreadyInstalled = !!installedManifest?.version;
|
|
136
|
+
let updated = false;
|
|
137
|
+
if (!alreadyInstalled || input.update) {
|
|
138
|
+
const signal = input.signal ?? AbortSignal.timeout(INSTALL_TIMEOUT_MS);
|
|
139
|
+
const run = input.runner ?? defaultRunner;
|
|
140
|
+
const npmResult = await run("npm", [
|
|
141
|
+
"install",
|
|
142
|
+
"--prefix",
|
|
143
|
+
pluginsDir,
|
|
144
|
+
npmInstallTarget(parsed.name, parsed.version, input.update),
|
|
145
|
+
], { cwd: pluginsDir, signal });
|
|
146
|
+
if (npmResult.exitCode !== 0)
|
|
147
|
+
throw npmInstallError(parsed.name, parsed.version, input.update, pluginsDir, npmResult);
|
|
148
|
+
updated = !!input.update && alreadyInstalled;
|
|
149
|
+
}
|
|
150
|
+
const manifest = (await readJson(join(packageDir, "package.json")));
|
|
151
|
+
const installedVersion = manifest?.version ?? "unknown";
|
|
152
|
+
const configFile = await persistGlobalPluginEntry(home, parsed.name);
|
|
153
|
+
return {
|
|
154
|
+
packageName: parsed.name,
|
|
155
|
+
installedVersion,
|
|
156
|
+
path: packageDir,
|
|
157
|
+
configEntry: parsed.name,
|
|
158
|
+
configFile,
|
|
159
|
+
pluginsDir,
|
|
160
|
+
alreadyInstalled,
|
|
161
|
+
updated,
|
|
162
|
+
trustRemark: pluginTrustRemark(parsed.name),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Final remark about loading and project trust, printed by the CLI and returned by the tool. */
|
|
166
|
+
export function pluginTrustRemark(name) {
|
|
167
|
+
return (`The plugin "${name}" is installed globally for your user; it loads as trusted personal code ` +
|
|
168
|
+
`wherever global plugins load — use \`alisio --trust-project\` or accept the one-time trust prompt ` +
|
|
169
|
+
`in a project so its configuration and project plugins load too. \`--read-only\` keeps the plugin ` +
|
|
170
|
+
`from loading. Run \`alisio plugins list\` to inspect.`);
|
|
171
|
+
}
|
|
172
|
+
const PREINSTALL_WARNING = `Warning: "npm install" may run the package's lifecycle scripts (preinstall, install, postinstall) ` +
|
|
173
|
+
`with your user privileges. Alisio does not sandbox plugin installation or plugin code; the plugin ` +
|
|
174
|
+
`runs in-process if it is loaded. Only install packages you trust.`;
|
|
175
|
+
export async function cliInstall(options) {
|
|
176
|
+
// Validate the spec before any network operation or prompt.
|
|
177
|
+
const parsed = parsePluginSpec(options.spec);
|
|
178
|
+
if (options.readOnly)
|
|
179
|
+
throw new Error(`Refusing to install npm plugin "${parsed.name}" under --read-only: installing runs npm and writes your global configuration. Run the command without --read-only.`);
|
|
180
|
+
if (options.yes) {
|
|
181
|
+
// Explicit opt-in: no warning, no prompt.
|
|
182
|
+
}
|
|
183
|
+
else if (options.interactive) {
|
|
184
|
+
process.stderr.write(`\n${PREINSTALL_WARNING}\n`);
|
|
185
|
+
const confirmed = await (options.confirm ?? defaultConfirm)();
|
|
186
|
+
if (!confirmed)
|
|
187
|
+
throw new Error(`Install of plugin "${parsed.name}" cancelled; nothing was installed or configured.`);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
// Headless (including --json): never prompt, fail with an actionable error.
|
|
191
|
+
throw new Error(`Refusing to install npm plugin "${parsed.name}" without confirmation: npm install may run ` +
|
|
192
|
+
`lifecycle scripts from the package. Re-run with --yes (or --trust-plugin) to confirm, for ` +
|
|
193
|
+
`example: alisio install npm:${npmInstallTarget(parsed.name, parsed.version)} --yes`);
|
|
194
|
+
}
|
|
195
|
+
const result = await installPlugin({
|
|
196
|
+
spec: options.spec,
|
|
197
|
+
configHome: options.configHome,
|
|
198
|
+
update: options.update,
|
|
199
|
+
signal: options.signal,
|
|
200
|
+
runner: options.runner,
|
|
201
|
+
});
|
|
202
|
+
printInstallResult(result, { json: !!options.json });
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
async function defaultConfirm() {
|
|
206
|
+
const { createInterface } = await import("node:readline/promises");
|
|
207
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
208
|
+
try {
|
|
209
|
+
const answer = (await rl.question("Install this plugin package globally? [y/N] "))
|
|
210
|
+
.trim()
|
|
211
|
+
.toLowerCase();
|
|
212
|
+
return answer === "y" || answer === "yes";
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
rl.close();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
export function printInstallResult(result, options = {}) {
|
|
219
|
+
if (options.json) {
|
|
220
|
+
process.stdout.write(`${JSON.stringify({
|
|
221
|
+
packageName: result.packageName,
|
|
222
|
+
installedVersion: result.installedVersion,
|
|
223
|
+
path: result.path,
|
|
224
|
+
configEntry: result.configEntry,
|
|
225
|
+
configFile: result.configFile,
|
|
226
|
+
alreadyInstalled: result.alreadyInstalled,
|
|
227
|
+
updated: result.updated,
|
|
228
|
+
trustRemark: result.trustRemark,
|
|
229
|
+
}, null, 2)}\n`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (result.alreadyInstalled && !result.updated)
|
|
233
|
+
console.log(`Plugin "${result.packageName}" is already installed (v${result.installedVersion}). Use --update to refresh it to the latest version and keep its name.`);
|
|
234
|
+
else if (result.updated)
|
|
235
|
+
console.log(`Updated plugin "${result.packageName}" to v${result.installedVersion}.`);
|
|
236
|
+
else
|
|
237
|
+
console.log(`Installed plugin "${result.packageName}" v${result.installedVersion}.`);
|
|
238
|
+
console.log(` path: ${result.path}`);
|
|
239
|
+
console.log(` config: "plugins" entry "${result.configEntry}" in ${result.configFile}`);
|
|
240
|
+
console.log(result.trustRemark);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Registers the host-owned `plugin_install` tool (agent capability). It has process effect so it
|
|
244
|
+
* goes through the existing write/process/external permission gate (TUI approval or
|
|
245
|
+
* `--allow-process`; never available under `--read-only`, where it is not registered at all).
|
|
246
|
+
*/
|
|
247
|
+
export function registerPluginInstallTool(registry, options = {}) {
|
|
248
|
+
if (options.readOnly)
|
|
249
|
+
return;
|
|
250
|
+
const tool = {
|
|
251
|
+
name: "plugin_install",
|
|
252
|
+
effect: "process",
|
|
253
|
+
description: "Install an npm plugin package into Alisio's global plugins directory " +
|
|
254
|
+
"(<configHome>/plugins, for example ~/.config/alisio/plugins) and add its npm name to the " +
|
|
255
|
+
'global configuration "plugins" array so it can load across projects. Accepts "npm:<package>' +
|
|
256
|
+
"[@<version>]\" or a bare package name. npm may run the package's lifecycle scripts with your " +
|
|
257
|
+
"privileges, so this tool goes through the process permission gate like any subprocess; the " +
|
|
258
|
+
"installed plugin runs in-process wherever it is loaded and is disabled under --read-only.",
|
|
259
|
+
inputSchema: {
|
|
260
|
+
type: "object",
|
|
261
|
+
properties: { spec: { type: "string", minLength: 1 } },
|
|
262
|
+
required: ["spec"],
|
|
263
|
+
additionalProperties: false,
|
|
264
|
+
},
|
|
265
|
+
async execute(input, context) {
|
|
266
|
+
const result = await installPlugin({
|
|
267
|
+
spec: String(input.spec),
|
|
268
|
+
configHome: options.configHome,
|
|
269
|
+
runner: options.runner,
|
|
270
|
+
signal: context.signal,
|
|
271
|
+
});
|
|
272
|
+
return textResult(JSON.stringify({
|
|
273
|
+
packageName: result.packageName,
|
|
274
|
+
installedVersion: result.installedVersion,
|
|
275
|
+
path: result.path,
|
|
276
|
+
configEntry: result.configEntry,
|
|
277
|
+
configFile: result.configFile,
|
|
278
|
+
alreadyInstalled: result.alreadyInstalled,
|
|
279
|
+
updated: result.updated,
|
|
280
|
+
trustRemark: result.trustRemark,
|
|
281
|
+
}));
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
registry.register(tool);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Lists npm packages installed under `<root>/node_modules` that declare the `alisio-plugin`
|
|
288
|
+
* keyword — the same packages `plugins`-array entries resolve to. Used by `alisio plugins list`
|
|
289
|
+
* so an installed package shows up alongside file/directory plugins.
|
|
290
|
+
*/
|
|
291
|
+
export async function installedNpmPlugins(root) {
|
|
292
|
+
const packagesDir = join(root, "node_modules");
|
|
293
|
+
let entries;
|
|
294
|
+
try {
|
|
295
|
+
entries = await readdir(packagesDir, { withFileTypes: true });
|
|
296
|
+
}
|
|
297
|
+
catch (e) {
|
|
298
|
+
if (e.code === "ENOENT")
|
|
299
|
+
return [];
|
|
300
|
+
throw e;
|
|
301
|
+
}
|
|
302
|
+
const result = [];
|
|
303
|
+
const consider = async (dir) => {
|
|
304
|
+
const manifest = (await readJson(join(dir, "package.json")));
|
|
305
|
+
if (manifest && Array.isArray(manifest.keywords) && manifest.keywords.includes(PLUGIN_KEYWORD))
|
|
306
|
+
result.push(dir);
|
|
307
|
+
};
|
|
308
|
+
for (const entry of entries) {
|
|
309
|
+
if (!entry.isDirectory() || entry.name.startsWith("."))
|
|
310
|
+
continue;
|
|
311
|
+
if (entry.name.startsWith("@")) {
|
|
312
|
+
const scoped = await readdir(join(packagesDir, entry.name), { withFileTypes: true }).catch(() => []);
|
|
313
|
+
for (const scope of scoped)
|
|
314
|
+
if (scope.isDirectory())
|
|
315
|
+
await consider(join(packagesDir, entry.name, scope.name));
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
await consider(join(packagesDir, entry.name));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return result.sort();
|
|
322
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - Global: `<config home>/AGENTS.override.md` or `<config home>/AGENTS.md`.
|
|
5
5
|
* - Project: one file per directory from the workspace root down to cwd, choosing
|
|
6
|
-
* AGENTS.override.md > AGENTS.md > AGENT.md (legacy alias
|
|
6
|
+
* AGENTS.override.md > AGENTS.md > AGENT.md (legacy alias from the earliest alphas) >
|
|
7
7
|
* CLAUDE.md (only with `claudeMdFallback`). Concatenated root-first so the closest is last.
|
|
8
8
|
* - Nested files below other directories are attached lazily when tools touch paths under them,
|
|
9
9
|
* once per session per file (re-attached if the file changes).
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export declare const PLUGIN_KEYWORD = "alisio-plugin";
|
|
2
2
|
/** Paths start with `.`, are absolute, use `\`, end in a JS/TS extension or contain `/` unscoped. */
|
|
3
3
|
export declare function isPathSpec(spec: string): boolean;
|
|
4
|
-
/** Global package roots: NODE_PATH entries
|
|
4
|
+
/** Global package roots: NODE_PATH entries, the prefix of the running Node/npm, and the
|
|
5
|
+
* Alisio global plugins directory (`<configHome>/plugins`), where `alisio install` puts npm
|
|
6
|
+
* plugin packages. */
|
|
5
7
|
export declare function defaultGlobalRoots(): string[];
|
|
6
8
|
/**
|
|
7
9
|
* Returns the file to import (or the directory holding an alisio-plugin.json manifest).
|
package/dist/runtime/modules.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Resolution of explicitly trusted plugins given as a path or an npm package name. */
|
|
2
2
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { configHome } from "../config.js";
|
|
3
4
|
import { readJson } from "./fs.js";
|
|
4
5
|
export const PLUGIN_KEYWORD = "alisio-plugin";
|
|
5
6
|
/** Paths start with `.`, are absolute, use `\`, end in a JS/TS extension or contain `/` unscoped. */
|
|
@@ -11,7 +12,9 @@ export function isPathSpec(spec) {
|
|
|
11
12
|
/\.(?:[cm]?[jt]s|tsx|jsx)$/.test(spec) ||
|
|
12
13
|
(!spec.startsWith("@") && spec.includes("/")));
|
|
13
14
|
}
|
|
14
|
-
/** Global package roots: NODE_PATH entries
|
|
15
|
+
/** Global package roots: NODE_PATH entries, the prefix of the running Node/npm, and the
|
|
16
|
+
* Alisio global plugins directory (`<configHome>/plugins`), where `alisio install` puts npm
|
|
17
|
+
* plugin packages. */
|
|
15
18
|
export function defaultGlobalRoots() {
|
|
16
19
|
const prefix = process.env.npm_config_prefix ?? process.env.PREFIX ?? dirname(dirname(process.execPath));
|
|
17
20
|
return [
|
|
@@ -21,6 +24,7 @@ export function defaultGlobalRoots() {
|
|
|
21
24
|
process.platform === "win32"
|
|
22
25
|
? join(prefix, "node_modules")
|
|
23
26
|
: join(prefix, "lib", "node_modules"),
|
|
27
|
+
join(configHome(), "plugins"),
|
|
24
28
|
];
|
|
25
29
|
}
|
|
26
30
|
function entryOf(manifest) {
|
|
@@ -58,8 +62,11 @@ export async function resolvePluginSpec(spec, options) {
|
|
|
58
62
|
if (dirname(dir) === dir)
|
|
59
63
|
break;
|
|
60
64
|
}
|
|
61
|
-
for (const rootDir of options.globalRoots ?? defaultGlobalRoots())
|
|
65
|
+
for (const rootDir of options.globalRoots ?? defaultGlobalRoots()) {
|
|
62
66
|
candidates.push(join(rootDir, spec));
|
|
67
|
+
// `alisio install` writes npm packages under <root>/node_modules/<spec>.
|
|
68
|
+
candidates.push(join(rootDir, "node_modules", spec));
|
|
69
|
+
}
|
|
63
70
|
for (const dir of candidates) {
|
|
64
71
|
searched.push(dir);
|
|
65
72
|
const manifest = (await readJson(join(dir, "package.json")));
|
|
@@ -4,6 +4,15 @@ export interface ProcessResult {
|
|
|
4
4
|
exitCode: number;
|
|
5
5
|
truncated: boolean;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Detect a spawn failure caused by a missing executable (`ENOENT`), so tools can explain how to
|
|
9
|
+
* install the dependency instead of surfacing the raw OS error.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isMissingCommand(error: unknown): boolean;
|
|
12
|
+
/** Per-platform install commands for the ripgrep dependency. */
|
|
13
|
+
export declare const RIPGREP_INSTALL_HINT: string;
|
|
14
|
+
/** Human-readable detection status for `alisio doctor`. */
|
|
15
|
+
export declare function ripgrepDiagnostic(found: boolean): string | undefined;
|
|
7
16
|
export declare function runProcess(command: string, args: string[], options: {
|
|
8
17
|
cwd: string;
|
|
9
18
|
signal: AbortSignal;
|
package/dist/runtime/process.js
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Detect a spawn failure caused by a missing executable (`ENOENT`), so tools can explain how to
|
|
4
|
+
* install the dependency instead of surfacing the raw OS error.
|
|
5
|
+
*/
|
|
6
|
+
export function isMissingCommand(error) {
|
|
7
|
+
return (!!error &&
|
|
8
|
+
typeof error === "object" &&
|
|
9
|
+
"code" in error &&
|
|
10
|
+
error.code === "ENOENT");
|
|
11
|
+
}
|
|
12
|
+
/** Per-platform install commands for the ripgrep dependency. */
|
|
13
|
+
export const RIPGREP_INSTALL_HINT = "Install ripgrep (rg): `sudo apt install ripgrep` (Debian/Ubuntu), " +
|
|
14
|
+
"`brew install ripgrep` (macOS), `winget install BurntSushi.ripgrep.MSVC` or " +
|
|
15
|
+
"`scoop install ripgrep` (Windows). It powers the search_text and list_files tools.";
|
|
16
|
+
/** Human-readable detection status for `alisio doctor`. */
|
|
17
|
+
export function ripgrepDiagnostic(found) {
|
|
18
|
+
return found ? undefined : RIPGREP_INSTALL_HINT;
|
|
19
|
+
}
|
|
2
20
|
export async function runProcess(command, args, options) {
|
|
3
21
|
options.signal.throwIfAborted();
|
|
4
22
|
const signal = AbortSignal.any([
|
package/dist/tools/standard.js
CHANGED
|
@@ -4,13 +4,24 @@ import { dirname, relative } from "node:path";
|
|
|
4
4
|
import { textResult, } from "@alisio/sdk";
|
|
5
5
|
import { fileSize, readHead, readText } from "../runtime/fs.js";
|
|
6
6
|
import { safePath } from "../runtime/paths.js";
|
|
7
|
-
import { runProcess } from "../runtime/process.js";
|
|
7
|
+
import { isMissingCommand, RIPGREP_INSTALL_HINT, runProcess } from "../runtime/process.js";
|
|
8
8
|
import { EXECUTE_TIMEOUT_MS, MAX_NESTED_CALLS, runExecute } from "./execute.js";
|
|
9
9
|
import { searchWithFallback } from "./search.js";
|
|
10
10
|
import { DEFAULT_TIMEOUT_SECONDS, MAX_RESPONSE_BYTES, MAX_TIMEOUT_SECONDS, webfetch, } from "./webfetch.js";
|
|
11
11
|
const str = { type: "string" }, integer = { type: "integer", minimum: 0 };
|
|
12
12
|
export const objectSchema = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
13
13
|
export const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
14
|
+
/** Runs ripgrep and turns a missing binary into an actionable install message. */
|
|
15
|
+
async function runRipgrep(args, options) {
|
|
16
|
+
try {
|
|
17
|
+
return await runProcess("rg", args, options);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (isMissingCommand(error))
|
|
21
|
+
throw new Error(RIPGREP_INSTALL_HINT);
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
14
25
|
async function existing(path) {
|
|
15
26
|
const size = await fileSize(path);
|
|
16
27
|
if (size === undefined)
|
|
@@ -120,7 +131,7 @@ execCtx, websearchCtx) {
|
|
|
120
131
|
paths: filePaths,
|
|
121
132
|
async execute(i, c) {
|
|
122
133
|
const path = await safePath(c.workspace, String(i.path ?? "."));
|
|
123
|
-
const result = await
|
|
134
|
+
const result = await runRipgrep(["--files", "--glob", "!.git", "--glob", "!node_modules", "--", path], { cwd: c.workspace, signal: c.signal, maxBytes: 200_000 });
|
|
124
135
|
if (result.exitCode > 1 && !result.truncated)
|
|
125
136
|
throw new Error(result.stderr);
|
|
126
137
|
const all = result.stdout
|
|
@@ -146,7 +157,7 @@ execCtx, websearchCtx) {
|
|
|
146
157
|
paths: filePaths,
|
|
147
158
|
async execute(i, c) {
|
|
148
159
|
const path = await safePath(c.workspace, String(i.path ?? "."));
|
|
149
|
-
const r = await
|
|
160
|
+
const r = await runRipgrep([
|
|
150
161
|
"--json",
|
|
151
162
|
"--max-count",
|
|
152
163
|
"100",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the @alisio/core package version at runtime so the MCP client metadata stays in sync
|
|
3
|
+
* with the published package. Resolves the manifest relative to the module (`src/version.ts` →
|
|
4
|
+
* `../package.json` in the source tree; `dist/version.js` → `../package.json` when installed).
|
|
5
|
+
* Standalone binaries inject ALISIO_PACKAGE_VERSION at build time (scripts/binary-build.ts).
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadVersion(fromHere: string): string;
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* Fallback used when neither the build-time injection nor the package manifest is readable.
|
|
6
|
+
* A development marker, never a publish literal: it cannot silently desync from the package.
|
|
7
|
+
*/
|
|
8
|
+
const FALLBACK = "dev";
|
|
9
|
+
/**
|
|
10
|
+
* Reads the @alisio/core package version at runtime so the MCP client metadata stays in sync
|
|
11
|
+
* with the published package. Resolves the manifest relative to the module (`src/version.ts` →
|
|
12
|
+
* `../package.json` in the source tree; `dist/version.js` → `../package.json` when installed).
|
|
13
|
+
* Standalone binaries inject ALISIO_PACKAGE_VERSION at build time (scripts/binary-build.ts).
|
|
14
|
+
*/
|
|
15
|
+
export function loadVersion(fromHere) {
|
|
16
|
+
if (process.env.ALISIO_PACKAGE_VERSION)
|
|
17
|
+
return process.env.ALISIO_PACKAGE_VERSION;
|
|
18
|
+
try {
|
|
19
|
+
const manifest = join(dirname(fileURLToPath(fromHere)), "..", "package.json");
|
|
20
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
21
|
+
return typeof parsed.version === "string" && parsed.version ? parsed.version : FALLBACK;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return FALLBACK;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alisio/core",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.6",
|
|
4
4
|
"description": "Embeddable provider-agnostic Alisio agent core: tool loop, context compaction, local tools, MCP, plugin host and SQLite session store. Runs on Node >=22.16 and Bun.",
|
|
5
5
|
"author": "Gustavo Gutiérrez",
|
|
6
6
|
"license": "MIT",
|