@ingram-cloud/pulumi 2.0.0 → 2.1.1

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,13 @@ function agentBody(i) {
248
248
  auto_memory: i.autoMemory ?? null,
249
249
  memory_consolidation: i.memoryConsolidation ?? null,
250
250
  variables: i.variables ?? [],
251
+ // Array.isArray, not `?? []`: during preview an unresolved Output (a
252
+ // skill created in the same update) reaches diff as an unknown sentinel,
253
+ // not an array — treat it as empty and let the real values drive `up`.
254
+ skills: (Array.isArray(i.skills) ? i.skills : []).map((s) => ({
255
+ skill_id: s.skillId,
256
+ ...(s.version != null ? { version: s.version } : {}),
257
+ })),
251
258
  };
252
259
  }
253
260
  // Canonical, name-sorted signature of an agent's frozen UI templates: each
@@ -291,6 +298,12 @@ function agentContentSig(s) {
291
298
  const vs = [...(s.vector_store_ids ?? [])].sort();
292
299
  if (vs.length)
293
300
  base.vector_store_ids = vs;
301
+ // Skills, deliberately NOT sorted: reference order is the run's index-ceiling
302
+ // drop priority, so reordering the array is itself a content change worth a
303
+ // new version, not a no-op like reordering hosted tools or vector stores is.
304
+ const skills = s.skills ?? [];
305
+ if (skills.length)
306
+ base.skills = skills;
294
307
  return JSON.stringify(base);
295
308
  }
296
309
  // Map the camelCase resource inputs onto the canonical snapshot shape.
@@ -304,6 +317,13 @@ function inputsToSnapshot(i) {
304
317
  auto_memory: i.autoMemory,
305
318
  memory_consolidation: i.memoryConsolidation,
306
319
  variables: i.variables,
320
+ // Array.isArray, not `?? []`: during preview an unresolved Output (a
321
+ // skill created in the same update) reaches diff as an unknown sentinel,
322
+ // not an array — treat it as empty and let the real values drive `up`.
323
+ skills: (Array.isArray(i.skills) ? i.skills : []).map((s) => ({
324
+ skill_id: s.skillId,
325
+ ...(s.version != null ? { version: s.version } : {}),
326
+ })),
307
327
  // Resolve each template's bytes and hash so a HTML edit shows up in the
308
328
  // signature (the API's `content_hash` uses the same sha256-hex).
309
329
  ui_resources: resolveUiTemplates(i.uiTemplates).map((t) => ({
@@ -451,6 +471,10 @@ const agentProvider = {
451
471
  autoMemory: d.auto_memory ?? undefined,
452
472
  memoryConsolidation: d.memory_consolidation ?? undefined,
453
473
  variables: d.variables ?? [],
474
+ skills: (d.skills ?? []).map((s) => ({
475
+ skillId: s.skill_id,
476
+ version: s.version,
477
+ })),
454
478
  },
455
479
  };
456
480
  });
@@ -467,6 +491,7 @@ class IcAgent extends pulumi.dynamic.Resource {
467
491
  autoMemory: undefined,
468
492
  memoryConsolidation: undefined,
469
493
  variables: undefined,
494
+ skills: undefined,
470
495
  uiTemplates: undefined,
471
496
  publishNote: undefined,
472
497
  rolloutPercent: undefined,
@@ -481,6 +506,128 @@ class IcAgent extends pulumi.dynamic.Resource {
481
506
  }
482
507
  }
483
508
  exports.IcAgent = IcAgent;
509
+ /** Walk a skill directory into the parts the upload sends.
510
+ *
511
+ * `require` inline rather than a top-level import: Pulumi serializes a dynamic
512
+ * provider's closure, and this is the idiom the vector-store resource next door
513
+ * already uses to read local files. */
514
+ function resolveSkillFiles(dir) {
515
+ if (!dir)
516
+ throw new Error("skill: `path` is required");
517
+ const fs = require("node:fs");
518
+ const nodePath = require("node:path");
519
+ const root = nodePath.basename(nodePath.resolve(dir));
520
+ const out = [];
521
+ const walk = (at, prefix) => {
522
+ for (const entry of fs.readdirSync(at, { withFileTypes: true })) {
523
+ const full = nodePath.join(at, entry.name);
524
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
525
+ if (entry.isDirectory())
526
+ walk(full, rel);
527
+ else if (entry.isFile())
528
+ out.push({ rel: `${root}/${rel}`, bytes: fs.readFileSync(full) });
529
+ }
530
+ };
531
+ walk(nodePath.resolve(dir), "");
532
+ if (!out.some((f) => f.rel === `${root}/SKILL.md`))
533
+ throw new Error(`skill: ${dir} has no SKILL.md`);
534
+ return out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
535
+ }
536
+ /** A content signature over the whole bundle — what `diff` compares. */
537
+ function skillSig(dir) {
538
+ if (!dir)
539
+ return "";
540
+ const { createHash } = require("node:crypto");
541
+ const hash = createHash("sha256");
542
+ for (const file of resolveSkillFiles(dir)) {
543
+ hash.update(file.rel);
544
+ hash.update(file.bytes);
545
+ }
546
+ return hash.digest("hex");
547
+ }
548
+ /** Upload a bundle as repeated `files[]` parts. Not `icRequest` (that
549
+ * JSON-encodes); uses global FormData/File (Node ≥18), like `uploadFile`. */
550
+ async function uploadSkillBundle(baseUrl, token, path, dir) {
551
+ const form = new FormData();
552
+ for (const file of resolveSkillFiles(dir))
553
+ form.append("files[]", new File([file.bytes], file.rel));
554
+ const res = await fetch(`${baseUrl}${path}`, {
555
+ method: "POST",
556
+ headers: {
557
+ Authorization: `Bearer ${token}`,
558
+ "IC-Api-Version": exports.IC_API_VERSION,
559
+ },
560
+ body: form,
561
+ });
562
+ const text = await res.text();
563
+ if (!res.ok) {
564
+ const err = new Error(`IC POST ${path} → ${res.status}: ${text.slice(0, 400)}`);
565
+ err.status = res.status;
566
+ throw err;
567
+ }
568
+ return text ? JSON.parse(text) : {};
569
+ }
570
+ const skillProvider = {
571
+ async create(i) {
572
+ const skill = await uploadSkillBundle(i.baseUrl, i.token, "/v1/skills", i.path);
573
+ return {
574
+ id: skill.id,
575
+ outs: {
576
+ ...i,
577
+ skillId: skill.id,
578
+ version: skill.default_version,
579
+ contentSig: skillSig(i.path),
580
+ },
581
+ };
582
+ },
583
+ async update(id, _olds, news) {
584
+ // A version is immutable, so an edited bundle is a new one — and the
585
+ // default has to move, or nothing that resolves by default would see it.
586
+ const published = await uploadSkillBundle(news.baseUrl, news.token, `/v1/skills/${id}/versions`, news.path);
587
+ await icRequest(news.baseUrl, news.token, "POST", `/v1/skills/${id}`, {
588
+ default_version: published.version,
589
+ });
590
+ return {
591
+ outs: {
592
+ ...news,
593
+ skillId: id,
594
+ version: published.version,
595
+ contentSig: skillSig(news.path),
596
+ },
597
+ };
598
+ },
599
+ async delete(id, props) {
600
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/skills/${id}`);
601
+ },
602
+ async diff(_id, olds, news) {
603
+ // Compare against the sig STORED at the last apply, not a re-resolve of
604
+ // olds: olds.path points at the same folder, whose bytes have already
605
+ // changed, so re-reading it would hide the change (same reasoning as the
606
+ // agent and vector-store diffs).
607
+ const changed = (olds.contentSig ?? "") !== skillSig(news.path);
608
+ return { changes: changed };
609
+ },
610
+ async read(id, props) {
611
+ return read404(() => icRequest(props.baseUrl, props.token, "GET", `/v1/skills/${id}`), (skill) => {
612
+ if (!skill || !skill.id)
613
+ return { id: "" };
614
+ return {
615
+ id,
616
+ props: {
617
+ ...props,
618
+ skillId: skill.id,
619
+ version: skill.default_version,
620
+ },
621
+ };
622
+ });
623
+ },
624
+ };
625
+ class IcSkill extends pulumi.dynamic.Resource {
626
+ constructor(name, args, opts) {
627
+ super(skillProvider, name, { skillId: undefined, version: undefined, contentSig: undefined, ...args }, { ...opts, additionalSecretOutputs: ["token"] });
628
+ }
629
+ }
630
+ exports.IcSkill = IcSkill;
484
631
  // Read each declared file's bytes and hash them (same sha256-hex the API keys
485
632
  // files by) so an edit shows up in the file signature. Module-scope + node
486
633
  // 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.1",
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
  }