@alfe.ai/integrations 0.0.13 → 0.0.15

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
@@ -146,6 +146,7 @@ declare class InstallerError extends Error {
146
146
  }
147
147
  declare class Installer {
148
148
  private basePath;
149
+ private sharedPackagesReady;
149
150
  /**
150
151
  * @param basePath - Override the integrations directory (for testing).
151
152
  * Defaults to ~/.alfe/integrations/
@@ -159,6 +160,10 @@ declare class Installer {
159
160
  * Install an integration by cloning its git repo and checking out the pinned commit.
160
161
  * For monorepo integrations (with subdir), extracts only the subdir contents.
161
162
  *
163
+ * After cloning, ensures shared @alfe.ai packages are available at the root
164
+ * integrations directory, and runs `npm install --production` in the integration
165
+ * directory if it has its own package.json.
166
+ *
162
167
  * If the install path already exists (e.g. from a failed previous install),
163
168
  * it is cleaned up before retrying.
164
169
  */
@@ -180,6 +185,21 @@ declare class Installer {
180
185
  * Remove an installed integration.
181
186
  */
182
187
  remove(name: string): Promise<void>;
188
+ /**
189
+ * Ensure shared @alfe.ai packages are installed at the root integrations
190
+ * directory. Creates/updates a package.json and runs npm install.
191
+ *
192
+ * Node module resolution from hook scripts (e.g.
193
+ * ~/.alfe/integrations/console/hooks/post_install.js) walks up to
194
+ * ~/.alfe/integrations/node_modules/ and finds these packages.
195
+ */
196
+ private ensureSharedPackages;
197
+ /**
198
+ * Run `npm install --production` in an integration directory if it has
199
+ * its own package.json (for dependencies beyond the shared @alfe.ai packages).
200
+ */
201
+ private installLocalDependencies;
202
+ private runNpmInstall;
183
203
  /**
184
204
  * List all locally installed integrations.
185
205
  */
@@ -227,6 +247,18 @@ interface ConfigSchemaField {
227
247
  /** Only show this field if a specific integration is installed */
228
248
  depends_on_integration?: string;
229
249
  }
250
+ interface CommandDeclaration {
251
+ /** Dot-namespaced command name (e.g. "support.diagnostic") */
252
+ name: string;
253
+ /** Relative path to handler file within integration directory */
254
+ handler: string;
255
+ /** Exported function name (default: "handle") */
256
+ method?: string;
257
+ /** Timeout in milliseconds (default: 30000) */
258
+ timeout_ms?: number;
259
+ /** Human-readable description */
260
+ description?: string;
261
+ }
230
262
  interface SkillInstall {
231
263
  /** Relative path within the integration repo to the skill directory */
232
264
  path: string;
@@ -253,6 +285,7 @@ interface InstallTargets {
253
285
  interface IntegrationHooks {
254
286
  pre_install?: string;
255
287
  post_install?: string;
288
+ post_activate?: string;
256
289
  pre_uninstall?: string;
257
290
  post_uninstall?: string;
258
291
  health_check?: string;
@@ -290,6 +323,7 @@ interface IntegrationManifest {
290
323
  config_schema: ConfigSchemaField[];
291
324
  capabilities: string[];
292
325
  hooks: IntegrationHooks;
326
+ commands: CommandDeclaration[];
293
327
  /** Git repository URL (HTTPS) — not present in YAML, injected by the publish API */
294
328
  repository?: string;
295
329
  /**
@@ -480,6 +514,21 @@ declare class IntegrationManager {
480
514
  * Get a specific integration by name.
481
515
  */
482
516
  get(name: string): IntegrationInfo | undefined;
517
+ /**
518
+ * Get command declarations from all active integrations with resolved absolute paths.
519
+ * Used by the daemon to rebuild the command registry after reconciliation.
520
+ */
521
+ getActiveCommands(): {
522
+ integrationId: string;
523
+ commands: {
524
+ name: string;
525
+ handler: string;
526
+ resolvedPath: string;
527
+ method: string;
528
+ timeoutMs: number;
529
+ description?: string;
530
+ }[];
531
+ }[];
483
532
  /**
484
533
  * Clear in-memory secrets (called on daemon restart).
485
534
  */
@@ -537,8 +586,11 @@ declare class StateManager {
537
586
  /**
538
587
  * Hook Runner — executes integration lifecycle hook scripts.
539
588
  *
540
- * Hooks are shell scripts defined in the integration manifest.
541
- * They run as child processes with a 30-second timeout.
589
+ * Hooks are scripts defined in the integration manifest. The runner
590
+ * auto-detects the interpreter from the script's shebang line or file
591
+ * extension (.js/.mjs → node, .py → python3, default → bash).
592
+ *
593
+ * Scripts run as child processes with a 30-second timeout.
542
594
  * stdout/stderr are captured and returned.
543
595
  *
544
596
  * The runner injects standard environment variables:
@@ -575,9 +627,22 @@ interface HookEnvOptions {
575
627
  * - ALFE_<NAME_UPPER>_<KEY_UPPER>=value for each secret entry
576
628
  */
577
629
  declare function buildHookEnv(options: HookEnvOptions, additionalEnv?: Record<string, string>): Record<string, string>;
630
+ /**
631
+ * Resolve the interpreter for a hook script by inspecting its shebang
632
+ * line or file extension.
633
+ *
634
+ * Priority:
635
+ * 1. Shebang line (e.g. #!/usr/bin/env node → "node")
636
+ * 2. File extension (.js/.mjs → "node", .py → "python3")
637
+ * 3. Default → "bash"
638
+ */
639
+
578
640
  /**
579
641
  * Run a hook script from an integration directory.
580
642
  *
643
+ * The interpreter is auto-detected from the script's shebang line or
644
+ * file extension. See {@link resolveInterpreter}.
645
+ *
581
646
  * @param integrationPath - Base path of the integration (where alfe-integration.yaml lives)
582
647
  * @param hookScript - Relative path to the hook script (e.g. "scripts/activate.sh")
583
648
  * @param env - Additional environment variables to pass to the script
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { execFile, spawn } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { dirname, join } from "node:path";
4
4
  import { homedir, tmpdir } from "node:os";
5
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { closeSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { buildConfigValidationSchema, parseManifestFile } from "@alfe.ai/integration-manifest";
7
7
  import { createLogger } from "@auriclabs/logger";
8
8
  //#region src/registry.ts
@@ -124,10 +124,22 @@ var Resolver = class {
124
124
  * Each integration is cloned and checked out to a pinned commit hash.
125
125
  * For monorepo integrations (with subdir), only the subdir contents
126
126
  * are extracted — the full clone is discarded.
127
+ *
128
+ * Shared @alfe.ai packages (@alfe.ai/config, @alfe.ai/agent-api-client)
129
+ * are installed at the root integrations directory so all hook scripts
130
+ * can resolve them via Node's upward module resolution.
127
131
  */
128
132
  const execFileAsync$1 = promisify(execFile);
133
+ const log$1 = createLogger("Installer");
129
134
  const INTEGRATIONS_DIR = join(homedir(), ".alfe", "integrations");
130
135
  const GIT_TIMEOUT_MS = 6e4;
136
+ const NPM_TIMEOUT_MS = 6e4;
137
+ /** Shared @alfe.ai packages available to all integration hooks */
138
+ const SHARED_PACKAGES = {
139
+ "@alfe.ai/config": "latest",
140
+ "@alfe.ai/agent-api-client": "latest",
141
+ "@alfe.ai/doctor": "latest"
142
+ };
131
143
  var InstallerError = class extends Error {
132
144
  constructor(message) {
133
145
  super(message);
@@ -136,6 +148,7 @@ var InstallerError = class extends Error {
136
148
  };
137
149
  var Installer = class {
138
150
  basePath;
151
+ sharedPackagesReady = false;
139
152
  /**
140
153
  * @param basePath - Override the integrations directory (for testing).
141
154
  * Defaults to ~/.alfe/integrations/
@@ -153,6 +166,10 @@ var Installer = class {
153
166
  * Install an integration by cloning its git repo and checking out the pinned commit.
154
167
  * For monorepo integrations (with subdir), extracts only the subdir contents.
155
168
  *
169
+ * After cloning, ensures shared @alfe.ai packages are available at the root
170
+ * integrations directory, and runs `npm install --production` in the integration
171
+ * directory if it has its own package.json.
172
+ *
156
173
  * If the install path already exists (e.g. from a failed previous install),
157
174
  * it is cleaned up before retrying.
158
175
  */
@@ -165,6 +182,8 @@ var Installer = class {
165
182
  mkdirSync(this.basePath, { recursive: true });
166
183
  if (resolved.subdir) await this.cloneAndExtractSubdir(resolved, installPath);
167
184
  else await this.cloneDirect(resolved, installPath);
185
+ await this.ensureSharedPackages();
186
+ await this.installLocalDependencies(installPath);
168
187
  return installPath;
169
188
  }
170
189
  /**
@@ -251,6 +270,66 @@ var Installer = class {
251
270
  return Promise.resolve();
252
271
  }
253
272
  /**
273
+ * Ensure shared @alfe.ai packages are installed at the root integrations
274
+ * directory. Creates/updates a package.json and runs npm install.
275
+ *
276
+ * Node module resolution from hook scripts (e.g.
277
+ * ~/.alfe/integrations/console/hooks/post_install.js) walks up to
278
+ * ~/.alfe/integrations/node_modules/ and finds these packages.
279
+ */
280
+ async ensureSharedPackages() {
281
+ if (this.sharedPackagesReady) return;
282
+ const pkgJsonPath = join(this.basePath, "package.json");
283
+ const nodeModulesExists = existsSync(join(this.basePath, "node_modules"));
284
+ let needsInstall = false;
285
+ if (existsSync(pkgJsonPath)) try {
286
+ const deps = JSON.parse(readFileSync(pkgJsonPath, "utf-8")).dependencies ?? {};
287
+ needsInstall = Object.keys(SHARED_PACKAGES).some((pkg) => !(pkg in deps));
288
+ } catch {
289
+ needsInstall = true;
290
+ }
291
+ else needsInstall = true;
292
+ if (!needsInstall && !nodeModulesExists) needsInstall = true;
293
+ if (!needsInstall) {
294
+ this.sharedPackagesReady = true;
295
+ return;
296
+ }
297
+ const pkgJson = {
298
+ name: "alfe-integrations-root",
299
+ private: true,
300
+ type: "module",
301
+ dependencies: { ...SHARED_PACKAGES }
302
+ };
303
+ writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n", "utf-8");
304
+ log$1.info("Installing shared @alfe.ai packages for integration hooks");
305
+ await this.runNpmInstall(this.basePath);
306
+ this.sharedPackagesReady = true;
307
+ }
308
+ /**
309
+ * Run `npm install --production` in an integration directory if it has
310
+ * its own package.json (for dependencies beyond the shared @alfe.ai packages).
311
+ */
312
+ async installLocalDependencies(installPath) {
313
+ if (!existsSync(join(installPath, "package.json"))) return;
314
+ log$1.info({ path: installPath }, "Installing integration-specific npm dependencies");
315
+ await this.runNpmInstall(installPath);
316
+ }
317
+ async runNpmInstall(cwd) {
318
+ try {
319
+ await execFileAsync$1("npm", [
320
+ "install",
321
+ "--production",
322
+ "--no-audit",
323
+ "--no-fund"
324
+ ], {
325
+ cwd,
326
+ timeout: NPM_TIMEOUT_MS
327
+ });
328
+ } catch (err) {
329
+ throw new InstallerError(`npm install failed in ${cwd}: ${err instanceof Error ? err.message : String(err)}`);
330
+ }
331
+ }
332
+ /**
254
333
  * List all locally installed integrations.
255
334
  */
256
335
  list() {
@@ -515,8 +594,11 @@ var LockManager = class {
515
594
  /**
516
595
  * Hook Runner — executes integration lifecycle hook scripts.
517
596
  *
518
- * Hooks are shell scripts defined in the integration manifest.
519
- * They run as child processes with a 30-second timeout.
597
+ * Hooks are scripts defined in the integration manifest. The runner
598
+ * auto-detects the interpreter from the script's shebang line or file
599
+ * extension (.js/.mjs → node, .py → python3, default → bash).
600
+ *
601
+ * Scripts run as child processes with a 30-second timeout.
520
602
  * stdout/stderr are captured and returned.
521
603
  *
522
604
  * The runner injects standard environment variables:
@@ -571,8 +653,55 @@ function buildHookEnv(options, additionalEnv) {
571
653
  return env;
572
654
  }
573
655
  /**
656
+ * Resolve the interpreter for a hook script by inspecting its shebang
657
+ * line or file extension.
658
+ *
659
+ * Priority:
660
+ * 1. Shebang line (e.g. #!/usr/bin/env node → "node")
661
+ * 2. File extension (.js/.mjs → "node", .py → "python3")
662
+ * 3. Default → "bash"
663
+ */
664
+ function resolveInterpreter(scriptPath) {
665
+ try {
666
+ const fd = openSync(scriptPath, "r");
667
+ const buf = Buffer.alloc(256);
668
+ readSync(fd, buf, 0, 256, 0);
669
+ closeSync(fd);
670
+ const firstLine = buf.toString("utf-8").split("\n")[0];
671
+ if (firstLine.startsWith("#!")) {
672
+ const shebang = firstLine.slice(2).trim();
673
+ if (shebang.startsWith("/usr/bin/env ")) {
674
+ const parts = shebang.slice(13).trim().split(/\s+/);
675
+ return {
676
+ command: parts[0],
677
+ args: [...parts.slice(1), scriptPath]
678
+ };
679
+ }
680
+ return {
681
+ command: shebang.split(/\s+/)[0],
682
+ args: [scriptPath]
683
+ };
684
+ }
685
+ } catch {}
686
+ if (scriptPath.endsWith(".js") || scriptPath.endsWith(".mjs")) return {
687
+ command: "node",
688
+ args: [scriptPath]
689
+ };
690
+ if (scriptPath.endsWith(".py")) return {
691
+ command: "python3",
692
+ args: [scriptPath]
693
+ };
694
+ return {
695
+ command: "bash",
696
+ args: [scriptPath]
697
+ };
698
+ }
699
+ /**
574
700
  * Run a hook script from an integration directory.
575
701
  *
702
+ * The interpreter is auto-detected from the script's shebang line or
703
+ * file extension. See {@link resolveInterpreter}.
704
+ *
576
705
  * @param integrationPath - Base path of the integration (where alfe-integration.yaml lives)
577
706
  * @param hookScript - Relative path to the hook script (e.g. "scripts/activate.sh")
578
707
  * @param env - Additional environment variables to pass to the script
@@ -586,11 +715,12 @@ async function runHook(integrationPath, hookScript, env) {
586
715
  stderr: `Hook script not found: ${scriptPath} (skipped)`,
587
716
  timedOut: false
588
717
  };
718
+ const { command, args } = resolveInterpreter(scriptPath);
589
719
  return new Promise((resolve) => {
590
720
  let stdout = "";
591
721
  let stderr = "";
592
722
  let timedOut = false;
593
- const proc = spawn("bash", [scriptPath], {
723
+ const proc = spawn(command, args, {
594
724
  cwd: integrationPath,
595
725
  env: {
596
726
  ...process.env,
@@ -757,7 +887,7 @@ var IntegrationManager = class {
757
887
  config: config ?? {},
758
888
  secrets: this.secrets.get(name)
759
889
  });
760
- if (hookResult.exitCode !== 0) this.log.warn(`post_install hook failed (non-fatal): ${hookResult.stderr || hookResult.stdout}`);
890
+ if (hookResult.exitCode !== 0) throw new Error(`post_install hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
761
891
  }
762
892
  this.state.set(name, {
763
893
  status: "installed",
@@ -894,6 +1024,18 @@ var IntegrationManager = class {
894
1024
  if (plugins.length > 0 || skills.length > 0) this.lockManager.addEntries(runtimeName, integrationId, manifest.version, plugins, skills, installPath);
895
1025
  }
896
1026
  this.state.setStatus(integrationId, "active");
1027
+ if (manifest.hooks.post_activate) {
1028
+ this.log.info(`Running post_activate hook: ${manifest.hooks.post_activate}`);
1029
+ const hookResult = await runHookWithContext(installPath, manifest.hooks.post_activate, {
1030
+ integrationName: integrationId,
1031
+ config: entry.config,
1032
+ secrets: this.secrets.get(integrationId)
1033
+ });
1034
+ if (hookResult.exitCode !== 0) {
1035
+ this.state.setStatus(integrationId, "error", `post_activate hook failed: ${hookResult.stderr || hookResult.stdout}`);
1036
+ return this.err("POST_ACTIVATE_FAILED", `post_activate hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1037
+ }
1038
+ }
897
1039
  if (manifest.hooks.health_check) {
898
1040
  this.log.info(`Running health check: ${manifest.hooks.health_check}`);
899
1041
  const hookResult = await runHookWithContext(installPath, manifest.hooks.health_check, {
@@ -1106,6 +1248,37 @@ var IntegrationManager = class {
1106
1248
  };
1107
1249
  }
1108
1250
  /**
1251
+ * Get command declarations from all active integrations with resolved absolute paths.
1252
+ * Used by the daemon to rebuild the command registry after reconciliation.
1253
+ */
1254
+ getActiveCommands() {
1255
+ const result = [];
1256
+ for (const entry of this.state.list()) {
1257
+ if (entry.status !== "active") continue;
1258
+ const installPath = this.installer.getInstallPath(entry.id);
1259
+ const manifestPath = join(installPath, "alfe-integration.yaml");
1260
+ if (!existsSync(manifestPath)) continue;
1261
+ try {
1262
+ const manifest = parseManifestFile(manifestPath);
1263
+ if (manifest.commands.length === 0) continue;
1264
+ result.push({
1265
+ integrationId: entry.id,
1266
+ commands: manifest.commands.map((cmd) => ({
1267
+ name: cmd.name,
1268
+ handler: cmd.handler,
1269
+ resolvedPath: join(installPath, cmd.handler),
1270
+ method: cmd.method ?? "handle",
1271
+ timeoutMs: cmd.timeout_ms ?? 3e4,
1272
+ description: cmd.description
1273
+ }))
1274
+ });
1275
+ } catch (err) {
1276
+ this.log.warn(`Failed to read commands for "${entry.id}": ${err instanceof Error ? err.message : String(err)}`);
1277
+ }
1278
+ }
1279
+ return result;
1280
+ }
1281
+ /**
1109
1282
  * Clear in-memory secrets (called on daemon restart).
1110
1283
  */
1111
1284
  clear() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integrations",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@auriclabs/logger": "^0.1.1",
16
- "@alfe.ai/integration-manifest": "^0.0.4"
16
+ "@alfe.ai/integration-manifest": "^0.0.6"
17
17
  },
18
18
  "files": [
19
19
  "dist"