@vellumai/cli 0.6.0 → 0.6.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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Gateway client for authenticated requests to a hatched assistant's runtime.
3
+ *
4
+ * Encapsulates lockfile reading, guardian-token resolution, and
5
+ * authenticated fetch so callers can simply do:
6
+ *
7
+ * ```ts
8
+ * const client = new AssistantClient(); // active / latest
9
+ * const client = new AssistantClient({ assistantId: "my-bot" }); // by name
10
+ * await client.get("/healthz");
11
+ * await client.post("/messages/", { content: "hi" });
12
+ * ```
13
+ */
14
+
15
+ import {
16
+ findAssistantByName,
17
+ getActiveAssistant,
18
+ loadLatestAssistant,
19
+ } from "./assistant-config.js";
20
+ import { GATEWAY_PORT } from "./constants.js";
21
+ import { loadGuardianToken } from "./guardian-token.js";
22
+
23
+ const DEFAULT_TIMEOUT_MS = 30_000;
24
+ const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
25
+
26
+ export interface AssistantClientOpts {
27
+ assistantId?: string;
28
+ /**
29
+ * When provided alongside `orgId`, the client authenticates with a
30
+ * session token instead of a guardian token. The session token is
31
+ * sent as `Authorization: Bearer <sessionToken>` and the org id is
32
+ * sent via the `X-Vellum-Org-Id` header.
33
+ */
34
+ sessionToken?: string;
35
+ /** Required when `sessionToken` is provided. */
36
+ orgId?: string;
37
+ }
38
+
39
+ export interface RequestOpts {
40
+ timeout?: number;
41
+ signal?: AbortSignal;
42
+ headers?: Record<string, string>;
43
+ query?: Record<string, string>;
44
+ }
45
+
46
+ export class AssistantClient {
47
+ readonly runtimeUrl: string;
48
+
49
+ private readonly _assistantId: string;
50
+ private readonly token: string | undefined;
51
+ private readonly orgId: string | undefined;
52
+
53
+ /**
54
+ * Resolves an assistant entry from the lockfile and loads auth credentials.
55
+ *
56
+ * @param opts.assistantId - Explicit assistant name. When omitted, the
57
+ * active assistant is used, falling back to the most recently hatched one.
58
+ * @throws If no matching assistant is found.
59
+ */
60
+ constructor(opts?: AssistantClientOpts) {
61
+ const nameOrId = opts?.assistantId;
62
+ let entry = nameOrId ? findAssistantByName(nameOrId) : null;
63
+
64
+ if (nameOrId && !entry) {
65
+ throw new Error(`No assistant found with name '${nameOrId}'.`);
66
+ }
67
+
68
+ if (!entry) {
69
+ const active = getActiveAssistant();
70
+ if (active) {
71
+ entry = findAssistantByName(active);
72
+ }
73
+ }
74
+
75
+ if (!entry) {
76
+ entry = loadLatestAssistant();
77
+ }
78
+
79
+ if (!entry) {
80
+ throw new Error(
81
+ "No assistant found. Hatch one first with 'vellum hatch'.",
82
+ );
83
+ }
84
+
85
+ this.runtimeUrl = (
86
+ entry.localUrl ||
87
+ entry.runtimeUrl ||
88
+ FALLBACK_RUNTIME_URL
89
+ ).replace(/\/+$/, "");
90
+ this._assistantId = entry.assistantId;
91
+
92
+ if (opts?.sessionToken) {
93
+ // Platform assistant: use session token + org id header.
94
+ this.token = opts.sessionToken;
95
+ this.orgId = opts.orgId;
96
+ } else {
97
+ this.token =
98
+ loadGuardianToken(this._assistantId)?.accessToken ?? entry.bearerToken;
99
+ this.orgId = undefined;
100
+ }
101
+ }
102
+
103
+ /** GET request to the gateway. Auth headers are added automatically. */
104
+ async get(urlPath: string, opts?: RequestOpts): Promise<Response> {
105
+ return this.request("GET", urlPath, undefined, opts);
106
+ }
107
+
108
+ /**
109
+ * Subscribe to an SSE endpoint and yield parsed JSON objects from `data:` lines.
110
+ * Automatically sets `Accept: text/event-stream` and skips heartbeat comments.
111
+ */
112
+ async *stream<T = unknown>(
113
+ urlPath: string,
114
+ opts?: RequestOpts,
115
+ ): AsyncGenerator<T> {
116
+ const response = await this.get(urlPath, {
117
+ ...opts,
118
+ headers: { Accept: "text/event-stream", ...opts?.headers },
119
+ });
120
+
121
+ if (!response.ok) {
122
+ const body = await response.text().catch(() => "");
123
+ throw new Error(
124
+ `HTTP ${response.status}: ${body || response.statusText}`,
125
+ );
126
+ }
127
+
128
+ if (!response.body) {
129
+ throw new Error("No response body received.");
130
+ }
131
+
132
+ const decoder = new TextDecoder();
133
+ let buffer = "";
134
+
135
+ for await (const chunk of response.body) {
136
+ buffer += decoder.decode(chunk, { stream: true });
137
+
138
+ let boundary: number;
139
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
140
+ const frame = buffer.slice(0, boundary);
141
+ buffer = buffer.slice(boundary + 2);
142
+
143
+ if (!frame.trim() || frame.startsWith(":")) continue;
144
+
145
+ let data: string | undefined;
146
+ for (const line of frame.split("\n")) {
147
+ if (line.startsWith("data: ")) {
148
+ data = line.slice(6);
149
+ }
150
+ }
151
+
152
+ if (!data) continue;
153
+
154
+ try {
155
+ yield JSON.parse(data) as T;
156
+ } catch {
157
+ // Skip malformed JSON
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ /** POST request to the gateway with a JSON body. Auth headers are added automatically. */
164
+ async post(
165
+ urlPath: string,
166
+ body: unknown,
167
+ opts?: RequestOpts,
168
+ ): Promise<Response> {
169
+ return this.request("POST", urlPath, body, opts);
170
+ }
171
+
172
+ /** PATCH request to the gateway with a JSON body. Auth headers are added automatically. */
173
+ async patch(
174
+ urlPath: string,
175
+ body: unknown,
176
+ opts?: RequestOpts,
177
+ ): Promise<Response> {
178
+ return this.request("PATCH", urlPath, body, opts);
179
+ }
180
+
181
+ private async request(
182
+ method: string,
183
+ urlPath: string,
184
+ body: unknown | undefined,
185
+ opts?: RequestOpts,
186
+ ): Promise<Response> {
187
+ const qs = opts?.query
188
+ ? `?${new URLSearchParams(opts.query).toString()}`
189
+ : "";
190
+ const url = `${this.runtimeUrl}/v1/assistants/${this._assistantId}${urlPath}${qs}`;
191
+
192
+ const headers: Record<string, string> = { ...opts?.headers };
193
+ if (this.token) {
194
+ headers["Authorization"] ??= `Bearer ${this.token}`;
195
+ }
196
+ if (this.orgId) {
197
+ headers["X-Vellum-Org-Id"] ??= this.orgId;
198
+ }
199
+ if (body !== undefined) {
200
+ headers["Content-Type"] = "application/json";
201
+ }
202
+
203
+ const jsonBody = body !== undefined ? JSON.stringify(body) : undefined;
204
+
205
+ if (opts?.signal) {
206
+ return fetch(url, {
207
+ method,
208
+ headers,
209
+ body: jsonBody,
210
+ signal: opts.signal,
211
+ });
212
+ }
213
+
214
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
215
+ const controller = new AbortController();
216
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
217
+ try {
218
+ return await fetch(url, {
219
+ method,
220
+ headers,
221
+ body: jsonBody,
222
+ signal: controller.signal,
223
+ });
224
+ } finally {
225
+ clearTimeout(timeoutId);
226
+ }
227
+ }
228
+ }
package/src/lib/docker.ts CHANGED
@@ -92,51 +92,60 @@ async function downloadAndExtract(
92
92
  }
93
93
 
94
94
  /**
95
- * Installs Docker CLI, Colima, and Lima by downloading pre-built binaries
96
- * directly into ~/.vellum/bin/. No Homebrew or sudo required.
97
- *
98
- * Falls back to Homebrew if available (e.g. admin users who prefer it).
95
+ * Installs Docker CLI (and Colima + Lima on macOS) by downloading pre-built
96
+ * binaries directly into ~/.local/bin/. No Homebrew or sudo required.
99
97
  */
100
98
  async function installDockerToolchain(): Promise<void> {
101
- // Try Homebrew first if available — it handles updates and dependencies.
102
- let hasBrew = false;
103
- try {
104
- await execOutput("brew", ["--version"]);
105
- hasBrew = true;
106
- } catch {
107
- // brew not found
108
- }
99
+ const isMac = platform() === "darwin";
100
+ const isLinux = platform() === "linux";
101
+
102
+ mkdirSync(LOCAL_BIN_DIR, { recursive: true });
103
+
104
+ const cpuArch = releaseArch();
109
105
 
110
- if (hasBrew) {
111
- console.log("🐳 Docker not found. Installing via Homebrew...");
106
+ if (isLinux) {
107
+ // On Linux, Docker runs natively only need the Docker CLI.
108
+ console.log(
109
+ "🐳 Docker not found. Installing Docker CLI to ~/.local/bin/...",
110
+ );
111
+
112
+ const dockerArch = cpuArch === "aarch64" ? "aarch64" : "x86_64";
113
+ const dockerTarUrl = `https://download.docker.com/linux/static/stable/${dockerArch}/docker-27.5.1.tgz`;
114
+ const dockerTmpDir = join(LOCAL_BIN_DIR, ".docker-tmp");
115
+ mkdirSync(dockerTmpDir, { recursive: true });
112
116
  try {
113
- await exec("brew", ["install", "colima", "docker"]);
114
- return;
115
- } catch {
116
- console.log(
117
- " ⚠ Homebrew install failed, falling back to direct binary download...",
118
- );
117
+ await downloadAndExtract(dockerTarUrl, dockerTmpDir, "Docker CLI");
118
+ await exec("mv", [
119
+ join(dockerTmpDir, "docker", "docker"),
120
+ join(LOCAL_BIN_DIR, "docker"),
121
+ ]);
122
+ chmodSync(join(LOCAL_BIN_DIR, "docker"), 0o755);
123
+ } finally {
124
+ await exec("rm", ["-rf", dockerTmpDir]).catch(() => {});
119
125
  }
120
- }
121
126
 
122
- // Direct binary install — no sudo required.
123
- console.log(
124
- "🐳 Docker not found. Installing Docker, Colima, and Lima to ~/.local/bin/...",
125
- );
126
-
127
- mkdirSync(LOCAL_BIN_DIR, { recursive: true });
127
+ if (!existsSync(join(LOCAL_BIN_DIR, "docker"))) {
128
+ throw new Error(
129
+ "docker binary not found after installation. Please install Docker manually: https://docs.docker.com/engine/install/",
130
+ );
131
+ }
128
132
 
129
- const cpuArch = releaseArch();
130
- const isMac = platform() === "darwin";
133
+ console.log(" Docker CLI installed to ~/.local/bin/");
134
+ return;
135
+ }
131
136
 
132
137
  if (!isMac) {
133
138
  throw new Error(
134
- "Automatic Docker installation is only supported on macOS. " +
139
+ "Automatic Docker installation is only supported on macOS and Linux. " +
135
140
  "Please install Docker manually: https://docs.docker.com/engine/install/",
136
141
  );
137
142
  }
138
143
 
139
- // --- Docker CLI ---
144
+ console.log(
145
+ "🐳 Docker not found. Installing Docker, Colima, and Lima to ~/.local/bin/...",
146
+ );
147
+
148
+ // --- Docker CLI (macOS) ---
140
149
  // Docker publishes static binaries at download.docker.com.
141
150
  const dockerArch = cpuArch === "aarch64" ? "aarch64" : "x86_64";
142
151
  const dockerTarUrl = `https://download.docker.com/mac/static/stable/${dockerArch}/docker-27.5.1.tgz`;
@@ -223,13 +232,17 @@ async function ensureDockerInstalled(): Promise<void> {
223
232
  // Always add ~/.local/bin to PATH so previously installed binaries are found.
224
233
  ensureLocalBinOnPath();
225
234
 
226
- // Check that docker, colima, and limactl are all available. If any is
227
- // missing (e.g. partial install from a previous failure), re-run install.
235
+ const isLinux = platform() === "linux";
236
+
237
+ // On Linux, Docker runs natively — only need the docker CLI + daemon.
238
+ // On macOS, we also need Colima and Lima to provide a Linux VM.
228
239
  const toolchainComplete = await (async () => {
229
240
  try {
230
241
  await execOutput("docker", ["--version"]);
231
- await execOutput("colima", ["version"]);
232
- await execOutput("limactl", ["--version"]);
242
+ if (!isLinux) {
243
+ await execOutput("colima", ["version"]);
244
+ await execOutput("limactl", ["--version"]);
245
+ }
233
246
  return true;
234
247
  } catch {
235
248
  return false;
@@ -251,10 +264,19 @@ async function ensureDockerInstalled(): Promise<void> {
251
264
  }
252
265
  }
253
266
 
254
- // Verify the Docker daemon is reachable; start Colima if it isn't.
267
+ // Verify the Docker daemon is reachable.
255
268
  try {
256
269
  await exec("docker", ["info"]);
257
270
  } catch {
271
+ // On Linux, the daemon must already be running (systemd, etc.).
272
+ if (isLinux) {
273
+ throw new Error(
274
+ "Docker daemon is not running. Please start it with 'sudo systemctl start docker' " +
275
+ "or ensure the Docker service is enabled.",
276
+ );
277
+ }
278
+
279
+ // On macOS, try starting Colima.
258
280
  let hasColima = false;
259
281
  try {
260
282
  await execOutput("colima", ["version"]);
@@ -394,6 +416,15 @@ function walkUpForRepoRoot(startDir: string): string | undefined {
394
416
  return undefined;
395
417
  }
396
418
 
419
+ /**
420
+ * Returns `true` when the given root looks like a full source checkout
421
+ * (has assistant source code), as opposed to a packaged `.app` bundle
422
+ * that only contains the Dockerfiles.
423
+ */
424
+ function hasFullSourceTree(root: string): boolean {
425
+ return existsSync(join(root, "assistant", "package.json"));
426
+ }
427
+
397
428
  /**
398
429
  * Locate the repository root by walking up from `cli/src/lib/` until we
399
430
  * find a directory containing the expected Dockerfiles.
@@ -414,6 +445,20 @@ function findRepoRoot(): string {
414
445
  return execRoot;
415
446
  }
416
447
 
448
+ // Check the app bundle's Resources directory. Debug DMG builds bundle
449
+ // Dockerfiles at Contents/Resources/dockerfiles/{assistant,gateway,...}/Dockerfile.
450
+ // The CLI binary lives at Contents/MacOS/vellum-cli, so Resources is at
451
+ // ../Resources relative to the binary.
452
+ const bundledRoot = join(
453
+ dirname(process.execPath),
454
+ "..",
455
+ "Resources",
456
+ "dockerfiles",
457
+ );
458
+ if (existsSync(join(bundledRoot, "assistant", "Dockerfile"))) {
459
+ return bundledRoot;
460
+ }
461
+
417
462
  // Walk up from cwd as a final fallback
418
463
  const cwdRoot = walkUpForRepoRoot(process.cwd());
419
464
  if (cwdRoot) {
@@ -691,11 +736,13 @@ export async function sleepContainers(
691
736
  }
692
737
  }
693
738
 
694
- /** Start existing stopped containers, starting Colima first if it isn't running. */
739
+ /** Start existing stopped containers, starting Colima first if it isn't running (macOS only). */
695
740
  export async function wakeContainers(
696
741
  res: ReturnType<typeof dockerResourceNames>,
697
742
  ): Promise<void> {
698
- await ensureColimaRunning();
743
+ if (platform() !== "linux") {
744
+ await ensureColimaRunning();
745
+ }
699
746
  for (const container of [
700
747
  res.assistantContainer,
701
748
  res.gatewayContainer,
@@ -805,6 +852,9 @@ function affectedServices(
805
852
  * images and restart their containers.
806
853
  */
807
854
  function startFileWatcher(opts: {
855
+ signingKey?: string;
856
+ bootstrapSecret?: string;
857
+ cesServiceToken?: string;
808
858
  gatewayPort: number;
809
859
  imageTags: Record<ServiceName, string>;
810
860
  instanceName: string;
@@ -826,6 +876,9 @@ function startFileWatcher(opts: {
826
876
 
827
877
  const configs = serviceImageConfigs(repoRoot, imageTags);
828
878
  const runArgs = serviceDockerRunArgs({
879
+ signingKey: opts.signingKey,
880
+ bootstrapSecret: opts.bootstrapSecret,
881
+ cesServiceToken: opts.cesServiceToken,
829
882
  gatewayPort,
830
883
  imageTags,
831
884
  instanceName,
@@ -963,8 +1016,23 @@ export async function hatchDocker(
963
1016
  let repoRoot: string | undefined;
964
1017
 
965
1018
  if (watch) {
966
- emitProgress(2, 6, "Building images...");
967
1019
  repoRoot = findRepoRoot();
1020
+
1021
+ // When running from a packaged .app bundle, the Dockerfiles are
1022
+ // present (so findRepoRoot succeeds) but the full source tree is
1023
+ // not — we can't build images locally. Fall back to pulling
1024
+ // pre-built images instead.
1025
+ if (!hasFullSourceTree(repoRoot)) {
1026
+ log(
1027
+ "⚠️ Dockerfiles found but no source tree — falling back to image pull",
1028
+ );
1029
+ watch = false;
1030
+ repoRoot = undefined;
1031
+ }
1032
+ }
1033
+
1034
+ if (watch && repoRoot) {
1035
+ emitProgress(2, 6, "Building images...");
968
1036
  const localTag = `local-${instanceName}`;
969
1037
  imageTags.assistant = `vellum-assistant:${localTag}`;
970
1038
  imageTags.gateway = `vellum-gateway:${localTag}`;
@@ -983,20 +1051,41 @@ export async function hatchDocker(
983
1051
 
984
1052
  await buildAllImages(repoRoot, imageTags, log);
985
1053
  log("✅ Docker images built");
986
- } else {
1054
+ }
1055
+
1056
+ if (!watch || !repoRoot) {
987
1057
  emitProgress(2, 6, "Pulling images...");
988
- const version = cliPkg.version;
989
- const versionTag = version ? `v${version}` : "latest";
990
- log("🔍 Resolving image references...");
991
- const resolved = await resolveImageRefs(versionTag, log);
992
- imageTags.assistant = resolved.imageTags.assistant;
993
- imageTags.gateway = resolved.imageTags.gateway;
994
- imageTags["credential-executor"] =
995
- resolved.imageTags["credential-executor"];
1058
+
1059
+ // Allow explicit image overrides via environment variables.
1060
+ // When all three are set, skip version-based resolution entirely.
1061
+ const envAssistant = process.env.VELLUM_ASSISTANT_IMAGE;
1062
+ const envGateway = process.env.VELLUM_GATEWAY_IMAGE;
1063
+ const envCredentialExecutor =
1064
+ process.env.VELLUM_CREDENTIAL_EXECUTOR_IMAGE;
1065
+
1066
+ let imageSource: string;
1067
+
1068
+ if (envAssistant && envGateway && envCredentialExecutor) {
1069
+ imageTags.assistant = envAssistant;
1070
+ imageTags.gateway = envGateway;
1071
+ imageTags["credential-executor"] = envCredentialExecutor;
1072
+ imageSource = "env override";
1073
+ log("Using image overrides from environment variables");
1074
+ } else {
1075
+ const version = cliPkg.version;
1076
+ const versionTag = version ? `v${version}` : "latest";
1077
+ log("🔍 Resolving image references...");
1078
+ const resolved = await resolveImageRefs(versionTag, log);
1079
+ imageTags.assistant = resolved.imageTags.assistant;
1080
+ imageTags.gateway = resolved.imageTags.gateway;
1081
+ imageTags["credential-executor"] =
1082
+ resolved.imageTags["credential-executor"];
1083
+ imageSource = resolved.source;
1084
+ }
996
1085
 
997
1086
  log(`🥚 Hatching Docker assistant: ${instanceName}`);
998
1087
  log(` Species: ${species}`);
999
- log(` Images (${resolved.source}):`);
1088
+ log(` Images (${imageSource}):`);
1000
1089
  log(` assistant: ${imageTags.assistant}`);
1001
1090
  log(` gateway: ${imageTags.gateway}`);
1002
1091
  log(` credential-executor: ${imageTags["credential-executor"]}`);
@@ -1106,6 +1195,9 @@ export async function hatchDocker(
1106
1195
  saveAssistantEntry({ ...dockerEntry, watcherPid: process.pid });
1107
1196
 
1108
1197
  const stopWatcher = startFileWatcher({
1198
+ signingKey,
1199
+ bootstrapSecret,
1200
+ cesServiceToken,
1109
1201
  gatewayPort,
1110
1202
  imageTags,
1111
1203
  instanceName,
@@ -308,10 +308,13 @@ export async function hatchLocal(
308
308
  }
309
309
 
310
310
  // Lease a guardian token so the desktop app can import it on first launch
311
- // instead of hitting /v1/guardian/init itself.
311
+ // instead of hitting /v1/guardian/init itself. Use loopback to satisfy
312
+ // the daemon's local-only check — the mDNS runtimeUrl resolves to a LAN
313
+ // IP which the daemon rejects as non-loopback.
312
314
  emitProgress(6, 7, "Securing connection...");
315
+ const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
313
316
  try {
314
- await leaseGuardianToken(runtimeUrl, instanceName);
317
+ await leaseGuardianToken(loopbackUrl, instanceName);
315
318
  } catch (err) {
316
319
  console.error(`⚠️ Guardian token lease failed: ${err}`);
317
320
  }
@@ -25,10 +25,10 @@ export async function checkManagedHealth(
25
25
  };
26
26
  }
27
27
 
28
- let orgId: string;
28
+ let headers: Record<string, string>;
29
29
  try {
30
- const { fetchOrganizationId } = await import("./platform-client.js");
31
- orgId = await fetchOrganizationId(token, runtimeUrl);
30
+ const { authHeaders } = await import("./platform-client.js");
31
+ headers = await authHeaders(token, runtimeUrl);
32
32
  } catch (err) {
33
33
  return {
34
34
  status: "error (auth)",
@@ -44,11 +44,6 @@ export async function checkManagedHealth(
44
44
  HEALTH_CHECK_TIMEOUT_MS,
45
45
  );
46
46
 
47
- const headers: Record<string, string> = {
48
- "X-Session-Token": token,
49
- "Vellum-Organization-Id": orgId,
50
- };
51
-
52
47
  const response = await fetch(url, {
53
48
  signal: controller.signal,
54
49
  headers,
package/src/lib/ngrok.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
2
  import {
3
+ closeSync,
3
4
  existsSync,
4
5
  mkdirSync,
5
6
  openSync,
@@ -130,10 +131,11 @@ export function startNgrokProcess(
130
131
  logFilePath?: string,
131
132
  ): ChildProcess {
132
133
  let stdio: ("ignore" | "pipe" | number)[] = ["ignore", "pipe", "pipe"];
134
+ let fd: number | undefined;
133
135
  if (logFilePath) {
134
136
  const dir = dirname(logFilePath);
135
137
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
136
- const fd = openSync(logFilePath, "a");
138
+ fd = openSync(logFilePath, "a");
137
139
  stdio = ["ignore", fd, fd];
138
140
  }
139
141
 
@@ -141,6 +143,14 @@ export function startNgrokProcess(
141
143
  detached: true,
142
144
  stdio,
143
145
  });
146
+
147
+ // The child process inherits a duplicate of the fd via dup2, so the
148
+ // parent's copy is no longer needed. Close it to avoid leaking the
149
+ // file descriptor for the lifetime of the parent process.
150
+ if (fd !== undefined) {
151
+ closeSync(fd);
152
+ }
153
+
144
154
  return child;
145
155
  }
146
156