@ingram-cloud/pulumi 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -145,6 +145,35 @@ export const pageUrl = page.pageUrl; // https://cloud.ingram.tech/hosted/dep_…
145
145
  > change to any of those (or to `agentId`) **replaces** the page and mints a fresh
146
146
  > `pageUrl`. The agent's instructions change on the `IcAgent`, not here.
147
147
 
148
+ ### IcSkill (declare in the APP repo)
149
+
150
+ An [Agent Skills](https://cloud.ingram.tech/docs/skills) bundle: a folder
151
+ anchored by `SKILL.md`, uploaded whole and versioned server-side. Declared by
152
+ **local directory** — the folder is the unit — like `IcAgent`'s `uiTemplates`
153
+ and the vector-store-shaped file attachments elsewhere in this pattern. The
154
+ bytes are read at `pulumi up`; any edit inside the folder publishes a new
155
+ immutable version and moves `default_version` to it, because a version is
156
+ immutable and nothing resolving by default would otherwise see the change.
157
+
158
+ ```ts
159
+ // The folder is the unit. Its basename is the skill's name, and any edit
160
+ // inside it publishes a new immutable version and moves the default.
161
+ const vatFiling = new ic.IcSkill("vat-filing", {
162
+ ...conn,
163
+ path: join(__dirname, "skills/vat-filing"),
164
+ });
165
+
166
+ // Put it in front of a fleet. Order matters: a run that fills its search index
167
+ // keeps the earlier entries and drops the rest.
168
+ const biller = new ic.IcAgent("biller", {
169
+ ...conn,
170
+ instructions: AGENT_SPECS.biller.instructions,
171
+ skills: [{ skill_id: vatFiling.skillId }],
172
+ // A skill's scripts/ only run through the run_command hosted tool.
173
+ enabledHostedTools: ["run_command"],
174
+ });
175
+ ```
176
+
148
177
  ### IcMcpServer, IcOauthProvider, IcTelegramBot, IcWhatsAppConfig, IcEmail, IcWebhook, IcModelKey (declare in INFRA)
149
178
 
150
179
  ```ts
@@ -154,6 +183,12 @@ new ic.IcMcpServer("thornhill-mcp", {
154
183
  authKind: "bearer", secret: mcpSecret,
155
184
  });
156
185
 
186
+ // The tool list is discovered at create, not on every `pulumi up` — nothing in
187
+ // this stack changes when your *server's* tools do, so `toolsDiscovered` is what
188
+ // was true at registration. Ingram Cloud re-discovers on the run path once a
189
+ // manifest is an hour old; call `POST /v1/tenant/mcp/{serverName}/refresh` at the
190
+ // end of a deploy that changed your tools if an hour is too long to wait.
191
+
157
192
  // An `oauth`-kind server forwards each end-user's own stored token; its
158
193
  // provider record tells IC how tokens refresh. Webhook-delegation mode (a
159
194
  // refreshWebhook and NO clientSecret): IC POSTs { connection_id } to the
@@ -37,6 +37,13 @@ export interface AgentVariable {
37
37
  description?: string | null;
38
38
  required?: boolean;
39
39
  }
40
+ /** One skill reference in an agent's `skills` array. Omitted `version` follows
41
+ * the skill's `default_version` and is frozen at publish. Pair with
42
+ * {@link IcSkill}'s `skillId` output. */
43
+ export interface AgentSkillRef {
44
+ skillId: string;
45
+ version?: number;
46
+ }
40
47
  /** CSP domain allowlists for the sandboxed iframe (the `_meta.ui.csp` shape). */
41
48
  export interface UiCsp {
42
49
  connectDomains?: string[];
@@ -97,6 +104,15 @@ export interface IcAgentArgs {
97
104
  /** Opt into observational-memory consolidation (background, billed). Default off. */
98
105
  memoryConsolidation?: pulumi.Input<boolean>;
99
106
  variables?: pulumi.Input<AgentVariable[]>;
107
+ /**
108
+ * Skills (`skl_…`) attached to the agent, in order. Order is not cosmetic: a
109
+ * run whose skills together exceed the search-index ceiling keeps them in
110
+ * this order and drops the rest, so it is the survival priority. Folds into
111
+ * the content signature unsorted, unlike `enabledHostedTools`/`vectorStoreIds`
112
+ * — reordering is itself a content change. Pair with {@link IcSkill}'s
113
+ * `skillId` output.
114
+ */
115
+ skills?: pulumi.Input<AgentSkillRef[]>;
100
116
  /**
101
117
  * MCP Apps UI templates attached to the agent. Each template's HTML bundle is
102
118
  * uploaded when its bytes or metadata change, and its name + content hash +
@@ -117,6 +133,23 @@ export declare class IcAgent extends pulumi.dynamic.Resource {
117
133
  readonly activeVersion: pulumi.Output<number>;
118
134
  constructor(name: string, args: IcAgentArgs, opts?: pulumi.CustomResourceOptions);
119
135
  }
136
+ export interface IcSkillArgs {
137
+ baseUrl: pulumi.Input<string>;
138
+ token: pulumi.Input<string>;
139
+ /**
140
+ * Absolute path to the skill's directory — the folder anchored by `SKILL.md`,
141
+ * whose basename is the skill's name. Read at `pulumi up`; edit any file in it
142
+ * and a new immutable version is published and made the default.
143
+ */
144
+ path: pulumi.Input<string>;
145
+ }
146
+ export declare class IcSkill extends pulumi.dynamic.Resource {
147
+ /** The `skl_…` id (same as `.id`, exposed for convenience). */
148
+ readonly skillId: pulumi.Output<string>;
149
+ /** The version this stack last published, and the current `default_version`. */
150
+ readonly version: pulumi.Output<number>;
151
+ constructor(name: string, args: IcSkillArgs, opts?: pulumi.CustomResourceOptions);
152
+ }
120
153
  /** One source file on an {@link IcVectorStore}, provided by local path. */
121
154
  export interface VectorStoreFile {
122
155
  /** Absolute path to the file to ingest (read at `pulumi up`, like `htmlPath`). */
package/dist/resources.js CHANGED
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.IcWebPage = exports.IcProjectToken = exports.IcProject = exports.IcModelKey = exports.IcWebhook = exports.IcEmail = exports.IcWhatsAppConfig = exports.IcOauthProvider = exports.IcTelegramBot = exports.IcMcpServer = exports.IcVectorStore = exports.IcAgent = exports.IC_API_VERSION = void 0;
36
+ exports.IcWebPage = exports.IcProjectToken = exports.IcProject = exports.IcModelKey = exports.IcWebhook = exports.IcEmail = exports.IcWhatsAppConfig = exports.IcOauthProvider = exports.IcTelegramBot = exports.IcMcpServer = exports.IcVectorStore = exports.IcSkill = exports.IcAgent = exports.IC_API_VERSION = void 0;
37
37
  /**
38
38
  * Ingram Cloud /v1 tenant configuration as Pulumi dynamic resources.
39
39
  *
@@ -248,6 +248,10 @@ function agentBody(i) {
248
248
  auto_memory: i.autoMemory ?? null,
249
249
  memory_consolidation: i.memoryConsolidation ?? null,
250
250
  variables: i.variables ?? [],
251
+ skills: (i.skills ?? []).map((s) => ({
252
+ skill_id: s.skillId,
253
+ ...(s.version != null ? { version: s.version } : {}),
254
+ })),
251
255
  };
252
256
  }
253
257
  // Canonical, name-sorted signature of an agent's frozen UI templates: each
@@ -291,6 +295,12 @@ function agentContentSig(s) {
291
295
  const vs = [...(s.vector_store_ids ?? [])].sort();
292
296
  if (vs.length)
293
297
  base.vector_store_ids = vs;
298
+ // Skills, deliberately NOT sorted: reference order is the run's index-ceiling
299
+ // drop priority, so reordering the array is itself a content change worth a
300
+ // new version, not a no-op like reordering hosted tools or vector stores is.
301
+ const skills = s.skills ?? [];
302
+ if (skills.length)
303
+ base.skills = skills;
294
304
  return JSON.stringify(base);
295
305
  }
296
306
  // Map the camelCase resource inputs onto the canonical snapshot shape.
@@ -304,6 +314,10 @@ function inputsToSnapshot(i) {
304
314
  auto_memory: i.autoMemory,
305
315
  memory_consolidation: i.memoryConsolidation,
306
316
  variables: i.variables,
317
+ skills: (i.skills ?? []).map((s) => ({
318
+ skill_id: s.skillId,
319
+ ...(s.version != null ? { version: s.version } : {}),
320
+ })),
307
321
  // Resolve each template's bytes and hash so a HTML edit shows up in the
308
322
  // signature (the API's `content_hash` uses the same sha256-hex).
309
323
  ui_resources: resolveUiTemplates(i.uiTemplates).map((t) => ({
@@ -451,6 +465,10 @@ const agentProvider = {
451
465
  autoMemory: d.auto_memory ?? undefined,
452
466
  memoryConsolidation: d.memory_consolidation ?? undefined,
453
467
  variables: d.variables ?? [],
468
+ skills: (d.skills ?? []).map((s) => ({
469
+ skillId: s.skill_id,
470
+ version: s.version,
471
+ })),
454
472
  },
455
473
  };
456
474
  });
@@ -467,6 +485,7 @@ class IcAgent extends pulumi.dynamic.Resource {
467
485
  autoMemory: undefined,
468
486
  memoryConsolidation: undefined,
469
487
  variables: undefined,
488
+ skills: undefined,
470
489
  uiTemplates: undefined,
471
490
  publishNote: undefined,
472
491
  rolloutPercent: undefined,
@@ -481,6 +500,128 @@ class IcAgent extends pulumi.dynamic.Resource {
481
500
  }
482
501
  }
483
502
  exports.IcAgent = IcAgent;
503
+ /** Walk a skill directory into the parts the upload sends.
504
+ *
505
+ * `require` inline rather than a top-level import: Pulumi serializes a dynamic
506
+ * provider's closure, and this is the idiom the vector-store resource next door
507
+ * already uses to read local files. */
508
+ function resolveSkillFiles(dir) {
509
+ if (!dir)
510
+ throw new Error("skill: `path` is required");
511
+ const fs = require("node:fs");
512
+ const nodePath = require("node:path");
513
+ const root = nodePath.basename(nodePath.resolve(dir));
514
+ const out = [];
515
+ const walk = (at, prefix) => {
516
+ for (const entry of fs.readdirSync(at, { withFileTypes: true })) {
517
+ const full = nodePath.join(at, entry.name);
518
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
519
+ if (entry.isDirectory())
520
+ walk(full, rel);
521
+ else if (entry.isFile())
522
+ out.push({ rel: `${root}/${rel}`, bytes: fs.readFileSync(full) });
523
+ }
524
+ };
525
+ walk(nodePath.resolve(dir), "");
526
+ if (!out.some((f) => f.rel === `${root}/SKILL.md`))
527
+ throw new Error(`skill: ${dir} has no SKILL.md`);
528
+ return out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
529
+ }
530
+ /** A content signature over the whole bundle — what `diff` compares. */
531
+ function skillSig(dir) {
532
+ if (!dir)
533
+ return "";
534
+ const { createHash } = require("node:crypto");
535
+ const hash = createHash("sha256");
536
+ for (const file of resolveSkillFiles(dir)) {
537
+ hash.update(file.rel);
538
+ hash.update(file.bytes);
539
+ }
540
+ return hash.digest("hex");
541
+ }
542
+ /** Upload a bundle as repeated `files[]` parts. Not `icRequest` (that
543
+ * JSON-encodes); uses global FormData/File (Node ≥18), like `uploadFile`. */
544
+ async function uploadSkillBundle(baseUrl, token, path, dir) {
545
+ const form = new FormData();
546
+ for (const file of resolveSkillFiles(dir))
547
+ form.append("files[]", new File([file.bytes], file.rel));
548
+ const res = await fetch(`${baseUrl}${path}`, {
549
+ method: "POST",
550
+ headers: {
551
+ Authorization: `Bearer ${token}`,
552
+ "IC-Api-Version": exports.IC_API_VERSION,
553
+ },
554
+ body: form,
555
+ });
556
+ const text = await res.text();
557
+ if (!res.ok) {
558
+ const err = new Error(`IC POST ${path} → ${res.status}: ${text.slice(0, 400)}`);
559
+ err.status = res.status;
560
+ throw err;
561
+ }
562
+ return text ? JSON.parse(text) : {};
563
+ }
564
+ const skillProvider = {
565
+ async create(i) {
566
+ const skill = await uploadSkillBundle(i.baseUrl, i.token, "/v1/skills", i.path);
567
+ return {
568
+ id: skill.id,
569
+ outs: {
570
+ ...i,
571
+ skillId: skill.id,
572
+ version: skill.default_version,
573
+ contentSig: skillSig(i.path),
574
+ },
575
+ };
576
+ },
577
+ async update(id, _olds, news) {
578
+ // A version is immutable, so an edited bundle is a new one — and the
579
+ // default has to move, or nothing that resolves by default would see it.
580
+ const published = await uploadSkillBundle(news.baseUrl, news.token, `/v1/skills/${id}/versions`, news.path);
581
+ await icRequest(news.baseUrl, news.token, "POST", `/v1/skills/${id}`, {
582
+ default_version: published.version,
583
+ });
584
+ return {
585
+ outs: {
586
+ ...news,
587
+ skillId: id,
588
+ version: published.version,
589
+ contentSig: skillSig(news.path),
590
+ },
591
+ };
592
+ },
593
+ async delete(id, props) {
594
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/skills/${id}`);
595
+ },
596
+ async diff(_id, olds, news) {
597
+ // Compare against the sig STORED at the last apply, not a re-resolve of
598
+ // olds: olds.path points at the same folder, whose bytes have already
599
+ // changed, so re-reading it would hide the change (same reasoning as the
600
+ // agent and vector-store diffs).
601
+ const changed = (olds.contentSig ?? "") !== skillSig(news.path);
602
+ return { changes: changed };
603
+ },
604
+ async read(id, props) {
605
+ return read404(() => icRequest(props.baseUrl, props.token, "GET", `/v1/skills/${id}`), (skill) => {
606
+ if (!skill || !skill.id)
607
+ return { id: "" };
608
+ return {
609
+ id,
610
+ props: {
611
+ ...props,
612
+ skillId: skill.id,
613
+ version: skill.default_version,
614
+ },
615
+ };
616
+ });
617
+ },
618
+ };
619
+ class IcSkill extends pulumi.dynamic.Resource {
620
+ constructor(name, args, opts) {
621
+ super(skillProvider, name, { skillId: undefined, version: undefined, contentSig: undefined, ...args }, { ...opts, additionalSecretOutputs: ["token"] });
622
+ }
623
+ }
624
+ exports.IcSkill = IcSkill;
484
625
  // Read each declared file's bytes and hash them (same sha256-hex the API keys
485
626
  // files by) so an edit shows up in the file signature. Module-scope + node
486
627
  // built-ins required inside for Pulumi's closure serialization.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-cloud/pulumi",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Pulumi dynamic resources for the Ingram Cloud /v1 API — projects, project tokens, agents, MCP servers, channels, webhooks, model keys.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://cloud.ingram.tech",
@@ -29,10 +29,10 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@ingram-tech/oxlint-config": "^0.2.2",
32
- "@pulumi/pulumi": "^3.253.0",
33
- "@types/node": "^26.1.1",
34
- "oxfmt": "^0.59.0",
35
- "oxlint": "^1.74.0",
32
+ "@pulumi/pulumi": "^3.255.0",
33
+ "@types/node": "^26.1.2",
34
+ "oxfmt": "0.62.0",
35
+ "oxlint": "^1.77.0",
36
36
  "typescript": "^7.0.2"
37
37
  }
38
38
  }