@alisio/core 0.1.0-alpha.5 → 0.1.0-alpha.7

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/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";
@@ -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;
@@ -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: "0.1.0-alpha.1" }, { capabilities: {} });
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"
@@ -566,7 +566,13 @@ export async function discoverPlugins(root) {
566
566
  throw e;
567
567
  }
568
568
  return entries
569
- .filter((e) => (e.isFile() && /\.[cm]?[jt]s$/.test(e.name)) || e.isDirectory())
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 kept from 0.1.0-alpha.1) >
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 and the prefix of the running Node/npm. */
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).
@@ -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 and the prefix of the running Node/npm. */
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;
@@ -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([
@@ -25,6 +25,7 @@ export declare class SQLiteStore implements SessionStore {
25
25
  append(id: string, message: Message): void;
26
26
  setModel(id: string, model: string): void;
27
27
  compact(id: string, replaced: number, summary: Message): void;
28
+ overwrite(id: string, messages: Message[]): void;
28
29
  acquire(id: string): void;
29
30
  release(id: string): void;
30
31
  beginCall(id: string, call: ToolCall): void;