@harness-lab/cli 0.1.4 → 0.1.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/README.md CHANGED
@@ -66,7 +66,7 @@ harness skill install
66
66
  ```
67
67
 
68
68
  This creates `.agents/skills/harness-lab-workshop` in the current Harness Lab repo checkout.
69
- After install, the CLI prints the first recommended agent commands, starting with `/workshop reference`.
69
+ After install, the CLI prints the first recommended agent commands, starting with `Codex: $workshop reference` and `OpenCode: /workshop reference`.
70
70
 
71
71
  Default device/browser login:
72
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-lab/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "Participant-facing Harness Lab CLI for facilitator auth and workshop operations",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -33,6 +33,9 @@
33
33
  "bin": {
34
34
  "harness": "bin/harness.js"
35
35
  },
36
+ "dependencies": {
37
+ "chalk": "^5.6.2"
38
+ },
36
39
  "scripts": {
37
40
  "test": "node --test"
38
41
  }
package/src/io.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import readline from "node:readline/promises";
2
+ import { Chalk } from "chalk";
2
3
 
3
4
  export async function prompt(io, label) {
4
5
  const rl = readline.createInterface({
@@ -17,3 +18,179 @@ export async function prompt(io, label) {
17
18
  export function writeLine(stream, line = "") {
18
19
  stream.write(`${line}\n`);
19
20
  }
21
+
22
+ function supportsColor(stream, env = {}) {
23
+ if (!stream?.isTTY) {
24
+ return false;
25
+ }
26
+
27
+ if ("NO_COLOR" in env) {
28
+ return false;
29
+ }
30
+
31
+ return env.TERM !== "dumb";
32
+ }
33
+
34
+ function createStyler(stream, env) {
35
+ return new Chalk({ level: supportsColor(stream, env) ? 1 : 0 });
36
+ }
37
+
38
+ function getWrapWidth(stream) {
39
+ const width = Number(stream?.columns);
40
+ if (!Number.isFinite(width) || width <= 0) {
41
+ return 88;
42
+ }
43
+
44
+ return Math.max(60, Math.min(width, 100));
45
+ }
46
+
47
+ function wrapText(text, width, indent = "", subsequentIndent = indent) {
48
+ if (!text) {
49
+ return [indent.trimEnd()];
50
+ }
51
+
52
+ const firstLineWidth = Math.max(20, width - indent.length);
53
+ const laterLineWidth = Math.max(20, width - subsequentIndent.length);
54
+ const words = text.split(/\s+/).filter(Boolean);
55
+ const lines = [];
56
+ let currentPrefix = indent;
57
+ let currentText = "";
58
+
59
+ for (const word of words) {
60
+ const lineWidth = currentPrefix === indent ? firstLineWidth : laterLineWidth;
61
+ const candidate = currentText ? `${currentText} ${word}` : word;
62
+
63
+ if (candidate.length <= lineWidth || currentText.length === 0) {
64
+ currentText = candidate;
65
+ continue;
66
+ }
67
+
68
+ lines.push(`${currentPrefix}${currentText}`);
69
+ currentPrefix = subsequentIndent;
70
+ currentText = word;
71
+ }
72
+
73
+ lines.push(`${currentPrefix}${currentText}`);
74
+ return lines;
75
+ }
76
+
77
+ function writeWrapped(stream, text, options = {}) {
78
+ const width = options.width ?? getWrapWidth(stream);
79
+ const indent = options.indent ?? "";
80
+ const subsequentIndent = options.subsequentIndent ?? indent;
81
+
82
+ for (const line of wrapText(text, width, indent, subsequentIndent)) {
83
+ writeLine(stream, line);
84
+ }
85
+ }
86
+
87
+ export function createCliUi(io) {
88
+ function streamFor(target) {
89
+ return target === "stderr" ? io.stderr : io.stdout;
90
+ }
91
+
92
+ function blank(target = "stdout") {
93
+ writeLine(streamFor(target));
94
+ }
95
+
96
+ function heading(title, options = {}) {
97
+ const target = options.stream ?? "stdout";
98
+ const stream = streamFor(target);
99
+ const chalk = createStyler(stream, io.env);
100
+ writeLine(stream, chalk.cyan.bold(title));
101
+ writeLine(stream, chalk.dim("=".repeat(title.length)));
102
+ }
103
+
104
+ function section(title, options = {}) {
105
+ const target = options.stream ?? "stdout";
106
+ const stream = streamFor(target);
107
+ const chalk = createStyler(stream, io.env);
108
+ writeLine(stream, chalk.bold(title));
109
+ }
110
+
111
+ function paragraph(text, options = {}) {
112
+ writeWrapped(streamFor(options.stream ?? "stdout"), text, options);
113
+ }
114
+
115
+ function status(kind, text, options = {}) {
116
+ const target = options.stream ?? (kind === "error" ? "stderr" : "stdout");
117
+ const stream = streamFor(target);
118
+ const chalk = createStyler(stream, io.env);
119
+ const prefixMap = {
120
+ ok: chalk.green.bold("[ok]"),
121
+ info: chalk.cyan.bold("[info]"),
122
+ warn: chalk.yellow.bold("[warn]"),
123
+ error: chalk.red.bold("[error]"),
124
+ };
125
+ const prefix = prefixMap[kind] ?? "[info]";
126
+ writeWrapped(stream, `${prefix} ${text}`, {
127
+ indent: options.indent ?? "",
128
+ subsequentIndent: options.subsequentIndent ?? " ",
129
+ width: options.width,
130
+ });
131
+ }
132
+
133
+ function keyValue(label, value, options = {}) {
134
+ const target = options.stream ?? "stdout";
135
+ const stream = streamFor(target);
136
+ const indent = options.indent ?? " ";
137
+ const subsequentIndent = options.subsequentIndent ?? " ";
138
+ const renderedValue = String(value);
139
+
140
+ if (!/\s/.test(renderedValue)) {
141
+ writeLine(stream, `${indent}${label}: ${renderedValue}`);
142
+ return;
143
+ }
144
+
145
+ writeWrapped(stream, `${label}: ${value}`, {
146
+ indent,
147
+ subsequentIndent,
148
+ width: options.width,
149
+ });
150
+ }
151
+
152
+ function numberedList(items, options = {}) {
153
+ const target = options.stream ?? "stdout";
154
+ const stream = streamFor(target);
155
+
156
+ items.forEach((item, index) => {
157
+ const indent = ` ${index + 1}. `;
158
+ writeWrapped(stream, item, {
159
+ indent,
160
+ subsequentIndent: " ",
161
+ width: options.width,
162
+ });
163
+ });
164
+ }
165
+
166
+ function commandList(items, options = {}) {
167
+ const target = options.stream ?? "stdout";
168
+ const stream = streamFor(target);
169
+
170
+ for (const item of items) {
171
+ writeWrapped(stream, item, {
172
+ indent: " ",
173
+ subsequentIndent: " ",
174
+ width: options.width,
175
+ });
176
+ }
177
+ }
178
+
179
+ function json(title, value, options = {}) {
180
+ const target = options.stream ?? "stdout";
181
+ heading(title, { stream: target });
182
+ writeLine(streamFor(target), JSON.stringify(value, null, 2));
183
+ }
184
+
185
+ return {
186
+ blank,
187
+ heading,
188
+ section,
189
+ paragraph,
190
+ status,
191
+ keyValue,
192
+ numberedList,
193
+ commandList,
194
+ json,
195
+ };
196
+ }
package/src/run-cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { getDefaultDashboardUrl } from "./config.js";
2
2
  import { createHarnessClient, HarnessApiError } from "./client.js";
3
- import { prompt, writeLine } from "./io.js";
3
+ import { createCliUi, prompt, writeLine } from "./io.js";
4
4
  import { deleteSession, readSession, sanitizeSession, writeSession, getSessionStorageMode, SessionStoreError } from "./session-store.js";
5
5
  import { installWorkshopSkill, SkillInstallError } from "./skill-install.js";
6
6
  import { createRequire } from "node:module";
7
+ import { pathToFileURL } from "node:url";
7
8
 
8
9
  const require = createRequire(import.meta.url);
9
10
  const { version } = require("../package.json");
@@ -63,38 +64,57 @@ async function readJson(response) {
63
64
  }
64
65
  }
65
66
 
66
- function printUsage(io) {
67
- writeLine(io.stdout, "Usage:");
68
- writeLine(io.stdout, " harness --help");
69
- writeLine(io.stdout, " harness --version");
70
- writeLine(io.stdout, " harness version");
71
- writeLine(io.stdout, " harness auth login [--auth device|basic|neon] [--dashboard-url URL] [--username USER] [--email EMAIL] [--password PASS] [--no-open]");
72
- writeLine(io.stdout, " harness auth logout");
73
- writeLine(io.stdout, " harness auth status");
74
- writeLine(io.stdout, " harness skill install [--force]");
75
- writeLine(io.stdout, " harness workshop status");
76
- writeLine(io.stdout, " harness workshop archive [--notes TEXT]");
77
- writeLine(io.stdout, " harness workshop phase set <phase-id>");
67
+ function printUsage(io, ui) {
68
+ ui.heading("Harness CLI");
69
+ ui.paragraph(`Version ${version}`);
70
+ ui.blank();
71
+ ui.section("Usage");
72
+ ui.commandList([
73
+ "harness --help",
74
+ "harness --version",
75
+ "harness version",
76
+ ]);
77
+ ui.blank();
78
+ ui.section("Commands");
79
+ ui.commandList([
80
+ "harness auth login [--auth device|basic|neon] [--dashboard-url URL] [--username USER] [--email EMAIL] [--password PASS] [--no-open]",
81
+ "harness auth logout",
82
+ "harness auth status",
83
+ "harness skill install [--force]",
84
+ "harness workshop status",
85
+ "harness workshop archive [--notes TEXT]",
86
+ "harness workshop phase set <phase-id>",
87
+ ]);
78
88
  }
79
89
 
80
90
  function printVersion(io) {
81
91
  writeLine(io.stdout, `harness ${version}`);
82
92
  }
83
93
 
84
- async function handleSkillInstall(io, deps, flags) {
94
+ async function handleSkillInstall(io, ui, deps, flags) {
85
95
  try {
86
96
  const result = await installWorkshopSkill(deps.cwd ?? process.cwd(), { force: flags.force === true });
87
- writeLine(io.stdout, `Installed Harness Lab workshop skill to ${result.installPath}`);
88
- writeLine(io.stdout, "Codex and OpenCode should now discover it from this repo via .agents/skills.");
89
- writeLine(io.stdout, "Next steps:");
90
- writeLine(io.stdout, " 1. Open Codex or OpenCode in this repo.");
91
- writeLine(io.stdout, " 2. Run `/workshop reference` for the command overview.");
92
- writeLine(io.stdout, " 3. Run `/workshop setup` if your environment is not ready yet.");
93
- writeLine(io.stdout, " 4. Run `/workshop` to get the current phase or fallback guidance.");
97
+ ui.heading("Workshop Skill");
98
+ if (result.mode === "already_bundled") {
99
+ ui.status("ok", "Harness Lab workshop skill is already bundled in this repo.");
100
+ } else {
101
+ ui.status("ok", "Installed the Harness Lab workshop skill bundle.");
102
+ }
103
+ ui.keyValue("Location", result.installPath);
104
+ ui.keyValue("Discovery", ".agents/skills");
105
+ ui.blank();
106
+ ui.section("Next steps");
107
+ ui.numberedList([
108
+ "Open Codex or OpenCode in this repo.",
109
+ "Start with the workshop reference card.",
110
+ "Codex: `$workshop reference`. OpenCode: `/workshop reference`.",
111
+ "Need setup help? Codex: `$workshop setup`. OpenCode: `/workshop setup`.",
112
+ "Other workshop commands follow the same pattern: `$workshop ...` in Codex and `/workshop ...` in OpenCode.",
113
+ ]);
94
114
  return 0;
95
115
  } catch (error) {
96
116
  if (error instanceof SkillInstallError) {
97
- writeLine(io.stderr, `Skill install failed: ${error.message}`);
117
+ ui.status("error", `Skill install failed: ${error.message}`, { stream: "stderr" });
98
118
  return 1;
99
119
  }
100
120
  throw error;
@@ -109,23 +129,23 @@ function formatStorageError(error) {
109
129
  return "Harness CLI could not access the configured session store.";
110
130
  }
111
131
 
112
- async function persistSession(io, env, session) {
132
+ async function persistSession(io, ui, env, session) {
113
133
  try {
114
134
  await writeSession(env, session);
115
135
  return true;
116
136
  } catch (error) {
117
- writeLine(io.stderr, `Session storage failed: ${formatStorageError(error)}`);
137
+ ui.status("error", `Session storage failed: ${formatStorageError(error)}`, { stream: "stderr" });
118
138
  return false;
119
139
  }
120
140
  }
121
141
 
122
- async function handleBasicAuthLogin(io, env, flags, deps) {
142
+ async function handleBasicAuthLogin(io, ui, env, flags, deps) {
123
143
  const dashboardUrl = String(flags["dashboard-url"] ?? getDefaultDashboardUrl(env));
124
144
  const username = String(flags.username ?? env.HARNESS_ADMIN_USERNAME ?? (await prompt(io, "Username: ")));
125
145
  const password = String(flags.password ?? env.HARNESS_ADMIN_PASSWORD ?? (await prompt(io, "Password: ")));
126
146
 
127
147
  if (!username || !password) {
128
- writeLine(io.stderr, "Username and password are required.");
148
+ ui.status("error", "Username and password are required.", { stream: "stderr" });
129
149
  return 1;
130
150
  }
131
151
 
@@ -142,31 +162,33 @@ async function handleBasicAuthLogin(io, env, flags, deps) {
142
162
 
143
163
  try {
144
164
  const payload = await client.verifyAccess();
145
- if (!(await persistSession(io, env, session))) {
165
+ if (!(await persistSession(io, ui, env, session))) {
146
166
  return 1;
147
167
  }
148
- writeLine(io.stdout, `Logged in to ${dashboardUrl}`);
149
- writeLine(io.stdout, `Session storage: ${getSessionStorageMode(env)}`);
168
+ ui.heading("Auth Login");
169
+ ui.status("ok", "Logged in.");
170
+ ui.keyValue("Dashboard", dashboardUrl);
171
+ ui.keyValue("Session storage", getSessionStorageMode(env));
150
172
  if (payload?.workshopId) {
151
- writeLine(io.stdout, `Workshop: ${payload.workshopId}`);
173
+ ui.keyValue("Workshop", payload.workshopId);
152
174
  }
153
175
  return 0;
154
176
  } catch (error) {
155
177
  if (error instanceof HarnessApiError) {
156
- writeLine(io.stderr, `Login failed: ${error.message}`);
178
+ ui.status("error", `Login failed: ${error.message}`, { stream: "stderr" });
157
179
  return 1;
158
180
  }
159
181
  throw error;
160
182
  }
161
183
  }
162
184
 
163
- async function handleNeonAuthLogin(io, env, flags, deps) {
185
+ async function handleNeonAuthLogin(io, ui, env, flags, deps) {
164
186
  const dashboardUrl = String(flags["dashboard-url"] ?? getDefaultDashboardUrl(env));
165
187
  const email = String(flags.email ?? env.HARNESS_FACILITATOR_EMAIL ?? (await prompt(io, "Email: ")));
166
188
  const password = String(flags.password ?? env.HARNESS_FACILITATOR_PASSWORD ?? (await prompt(io, "Password: ")));
167
189
 
168
190
  if (!email || !password) {
169
- writeLine(io.stderr, "Email and password are required.");
191
+ ui.status("error", "Email and password are required.", { stream: "stderr" });
170
192
  return 1;
171
193
  }
172
194
 
@@ -187,13 +209,13 @@ async function handleNeonAuthLogin(io, env, flags, deps) {
187
209
  : payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
188
210
  ? payload.error
189
211
  : `Login failed with status ${signInResponse.status}`;
190
- writeLine(io.stderr, `Login failed: ${message}`);
212
+ ui.status("error", `Login failed: ${message}`, { stream: "stderr" });
191
213
  return 1;
192
214
  }
193
215
 
194
216
  const setCookie = signInResponse.headers?.get?.("set-cookie");
195
217
  if (!setCookie) {
196
- writeLine(io.stderr, "Login failed: auth response did not include a session cookie.");
218
+ ui.status("error", "Login failed: auth response did not include a session cookie.", { stream: "stderr" });
197
219
  return 1;
198
220
  }
199
221
 
@@ -209,25 +231,27 @@ async function handleNeonAuthLogin(io, env, flags, deps) {
209
231
  try {
210
232
  const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
211
233
  const authSession = await client.getAuthSession();
212
- if (!(await persistSession(io, env, session))) {
234
+ if (!(await persistSession(io, ui, env, session))) {
213
235
  return 1;
214
236
  }
215
- writeLine(io.stdout, `Logged in to ${dashboardUrl}`);
216
- writeLine(io.stdout, `Session storage: ${getSessionStorageMode(env)}`);
237
+ ui.heading("Auth Login");
238
+ ui.status("ok", "Logged in.");
239
+ ui.keyValue("Dashboard", dashboardUrl);
240
+ ui.keyValue("Session storage", getSessionStorageMode(env));
217
241
  if (authSession?.user?.email) {
218
- writeLine(io.stdout, `Facilitator: ${authSession.user.email}`);
242
+ ui.keyValue("Facilitator", authSession.user.email);
219
243
  }
220
244
  return 0;
221
245
  } catch (error) {
222
246
  if (error instanceof HarnessApiError) {
223
- writeLine(io.stderr, `Session verification failed: ${error.message}`);
247
+ ui.status("error", `Session verification failed: ${error.message}`, { stream: "stderr" });
224
248
  return 1;
225
249
  }
226
250
  throw error;
227
251
  }
228
252
  }
229
253
 
230
- async function handleDeviceAuthLogin(io, env, flags, deps) {
254
+ async function handleDeviceAuthLogin(io, ui, env, flags, deps) {
231
255
  const dashboardUrl = String(flags["dashboard-url"] ?? getDefaultDashboardUrl(env));
232
256
  const client = createHarnessClient({
233
257
  fetchFn: deps.fetchFn,
@@ -236,10 +260,11 @@ async function handleDeviceAuthLogin(io, env, flags, deps) {
236
260
 
237
261
  try {
238
262
  const deviceAuth = await client.startDeviceAuthorization();
239
- writeLine(io.stdout, `Open: ${deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri}`);
240
- writeLine(io.stdout, `Code: ${deviceAuth.userCode}`);
241
- writeLine(io.stdout, `Expires: ${deviceAuth.expiresAt}`);
242
- writeLine(io.stdout, "Approve the login in a browser, then the CLI will continue automatically.");
263
+ ui.heading("Device Login");
264
+ ui.status("info", "Approve the login in a browser. The CLI will continue automatically.");
265
+ ui.keyValue("Open", deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri);
266
+ ui.keyValue("Code", deviceAuth.userCode);
267
+ ui.keyValue("Expires", deviceAuth.expiresAt);
243
268
 
244
269
  if (flags["no-open"] !== true && typeof deps.openUrl === "function") {
245
270
  await deps.openUrl(deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri);
@@ -265,42 +290,44 @@ async function handleDeviceAuthLogin(io, env, flags, deps) {
265
290
  expiresAt: result.expiresAt,
266
291
  };
267
292
 
268
- if (!(await persistSession(io, env, session))) {
293
+ if (!(await persistSession(io, ui, env, session))) {
269
294
  return 1;
270
295
  }
271
296
 
272
- writeLine(io.stdout, `Logged in to ${dashboardUrl}`);
273
- writeLine(io.stdout, `Session storage: ${getSessionStorageMode(env)}`);
297
+ ui.blank();
298
+ ui.status("ok", "Logged in.");
299
+ ui.keyValue("Dashboard", dashboardUrl);
300
+ ui.keyValue("Session storage", getSessionStorageMode(env));
274
301
  if (result.session?.role) {
275
- writeLine(io.stdout, `Facilitator role: ${result.session.role}`);
302
+ ui.keyValue("Facilitator role", result.session.role);
276
303
  }
277
304
  return 0;
278
305
  }
279
306
 
280
307
  if (result.status === "access_denied") {
281
- writeLine(io.stderr, "Login failed: device authorization was denied.");
308
+ ui.status("error", "Login failed: device authorization was denied.", { stream: "stderr" });
282
309
  return 1;
283
310
  }
284
311
 
285
312
  if (result.status === "expired_token") {
286
- writeLine(io.stderr, "Login failed: device authorization expired.");
313
+ ui.status("error", "Login failed: device authorization expired.", { stream: "stderr" });
287
314
  return 1;
288
315
  }
289
316
 
290
- writeLine(io.stderr, "Login failed: device authorization could not be completed.");
317
+ ui.status("error", "Login failed: device authorization could not be completed.", { stream: "stderr" });
291
318
  return 1;
292
319
  }
293
320
 
294
- writeLine(io.stderr, "Login failed: device authorization expired.");
321
+ ui.status("error", "Login failed: device authorization expired.", { stream: "stderr" });
295
322
  return 1;
296
323
  } catch (error) {
297
324
  if (error instanceof HarnessApiError) {
298
- writeLine(io.stderr, `Login failed: ${error.message}`);
325
+ ui.status("error", `Login failed: ${error.message}`, { stream: "stderr" });
299
326
  return 1;
300
327
  }
301
328
 
302
329
  if (error instanceof SessionStoreError) {
303
- writeLine(io.stderr, `Session storage failed: ${error.message}`);
330
+ ui.status("error", `Session storage failed: ${error.message}`, { stream: "stderr" });
304
331
  return 1;
305
332
  }
306
333
 
@@ -308,45 +335,45 @@ async function handleDeviceAuthLogin(io, env, flags, deps) {
308
335
  }
309
336
  }
310
337
 
311
- async function handleAuthLogin(io, env, flags, deps) {
338
+ async function handleAuthLogin(io, ui, env, flags, deps) {
312
339
  const authMode = String(flags.auth ?? env.HARNESS_AUTH_MODE ?? "device");
313
340
 
314
341
  if (authMode === "device") {
315
- return handleDeviceAuthLogin(io, env, flags, deps);
342
+ return handleDeviceAuthLogin(io, ui, env, flags, deps);
316
343
  }
317
344
 
318
345
  if (authMode === "neon") {
319
- return handleNeonAuthLogin(io, env, flags, deps);
346
+ return handleNeonAuthLogin(io, ui, env, flags, deps);
320
347
  }
321
348
 
322
- return handleBasicAuthLogin(io, env, flags, deps);
349
+ return handleBasicAuthLogin(io, ui, env, flags, deps);
323
350
  }
324
351
 
325
- async function requireSession(io, env) {
352
+ async function requireSession(io, ui, env) {
326
353
  try {
327
354
  const session = await readSession(env);
328
355
  if (!session) {
329
- writeLine(io.stderr, "No active session. Run `harness auth login` first.");
356
+ ui.status("error", "No active session. Run `harness auth login` first.", { stream: "stderr" });
330
357
  return null;
331
358
  }
332
359
  return session;
333
360
  } catch (error) {
334
- writeLine(io.stderr, `Session storage failed: ${formatStorageError(error)}`);
361
+ ui.status("error", `Session storage failed: ${formatStorageError(error)}`, { stream: "stderr" });
335
362
  return null;
336
363
  }
337
364
  }
338
365
 
339
- async function handleAuthStatus(io, env, deps) {
366
+ async function handleAuthStatus(io, ui, env, deps) {
340
367
  let session;
341
368
  try {
342
369
  session = await readSession(env);
343
370
  } catch (error) {
344
- writeLine(io.stderr, `Session storage failed: ${formatStorageError(error)}`);
371
+ ui.status("error", `Session storage failed: ${formatStorageError(error)}`, { stream: "stderr" });
345
372
  return 1;
346
373
  }
347
374
 
348
375
  if (!session) {
349
- writeLine(io.stdout, "Not logged in.");
376
+ ui.status("info", "Not logged in.");
350
377
  return 0;
351
378
  }
352
379
 
@@ -354,14 +381,11 @@ async function handleAuthStatus(io, env, deps) {
354
381
  try {
355
382
  const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
356
383
  const deviceSession = await client.getDeviceSession();
357
- writeLine(
358
- io.stdout,
359
- JSON.stringify({ ok: true, session: sanitizeSession(session, env), remoteSession: deviceSession.session }, null, 2),
360
- );
384
+ ui.json("Auth Status", { ok: true, session: sanitizeSession(session, env), remoteSession: deviceSession.session });
361
385
  return 0;
362
386
  } catch (error) {
363
387
  if (error instanceof HarnessApiError) {
364
- writeLine(io.stdout, JSON.stringify({ ok: false, session: sanitizeSession(session, env), error: error.message }, null, 2));
388
+ ui.json("Auth Status", { ok: false, session: sanitizeSession(session, env), error: error.message });
365
389
  return 1;
366
390
  }
367
391
  throw error;
@@ -372,30 +396,27 @@ async function handleAuthStatus(io, env, deps) {
372
396
  try {
373
397
  const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
374
398
  const authSession = await client.getAuthSession();
375
- writeLine(
376
- io.stdout,
377
- JSON.stringify({ ok: true, session: sanitizeSession(session, env), remoteSession: authSession }, null, 2),
378
- );
399
+ ui.json("Auth Status", { ok: true, session: sanitizeSession(session, env), remoteSession: authSession });
379
400
  return 0;
380
401
  } catch (error) {
381
402
  if (error instanceof HarnessApiError) {
382
- writeLine(io.stdout, JSON.stringify({ ok: false, session: sanitizeSession(session, env), error: error.message }, null, 2));
403
+ ui.json("Auth Status", { ok: false, session: sanitizeSession(session, env), error: error.message });
383
404
  return 1;
384
405
  }
385
406
  throw error;
386
407
  }
387
408
  }
388
409
 
389
- writeLine(io.stdout, JSON.stringify({ ok: true, session: sanitizeSession(session, env) }, null, 2));
410
+ ui.json("Auth Status", { ok: true, session: sanitizeSession(session, env) });
390
411
  return 0;
391
412
  }
392
413
 
393
- async function handleAuthLogout(io, env, deps) {
414
+ async function handleAuthLogout(io, ui, env, deps) {
394
415
  let session;
395
416
  try {
396
417
  session = await readSession(env);
397
418
  } catch (error) {
398
- writeLine(io.stderr, `Session storage failed: ${formatStorageError(error)}`);
419
+ ui.status("error", `Session storage failed: ${formatStorageError(error)}`, { stream: "stderr" });
399
420
  return 1;
400
421
  }
401
422
 
@@ -424,15 +445,15 @@ async function handleAuthLogout(io, env, deps) {
424
445
  try {
425
446
  await deleteSession(env);
426
447
  } catch (error) {
427
- writeLine(io.stderr, `Session storage failed: ${formatStorageError(error)}`);
448
+ ui.status("error", `Session storage failed: ${formatStorageError(error)}`, { stream: "stderr" });
428
449
  return 1;
429
450
  }
430
- writeLine(io.stdout, "Logged out.");
451
+ ui.status("ok", "Logged out.");
431
452
  return 0;
432
453
  }
433
454
 
434
- async function handleWorkshopStatus(io, env, deps) {
435
- const session = await requireSession(io, env);
455
+ async function handleWorkshopStatus(io, ui, env, deps) {
456
+ const session = await requireSession(io, ui, env);
436
457
  if (!session) {
437
458
  return 1;
438
459
  }
@@ -440,32 +461,25 @@ async function handleWorkshopStatus(io, env, deps) {
440
461
  try {
441
462
  const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
442
463
  const [workshop, agenda] = await Promise.all([client.getWorkshopStatus(), client.getAgenda()]);
443
- writeLine(
444
- io.stdout,
445
- JSON.stringify(
446
- {
447
- ok: true,
448
- workshopId: workshop.workshopId,
449
- workshopMeta: workshop.workshopMeta,
450
- currentPhase: agenda.phase,
451
- templates: workshop.templates,
452
- },
453
- null,
454
- 2,
455
- ),
456
- );
464
+ ui.json("Workshop Status", {
465
+ ok: true,
466
+ workshopId: workshop.workshopId,
467
+ workshopMeta: workshop.workshopMeta,
468
+ currentPhase: agenda.phase,
469
+ templates: workshop.templates,
470
+ });
457
471
  return 0;
458
472
  } catch (error) {
459
473
  if (error instanceof HarnessApiError) {
460
- writeLine(io.stderr, `Workshop status failed: ${error.message}`);
474
+ ui.status("error", `Workshop status failed: ${error.message}`, { stream: "stderr" });
461
475
  return 1;
462
476
  }
463
477
  throw error;
464
478
  }
465
479
  }
466
480
 
467
- async function handleWorkshopArchive(io, env, flags, deps) {
468
- const session = await requireSession(io, env);
481
+ async function handleWorkshopArchive(io, ui, env, flags, deps) {
482
+ const session = await requireSession(io, ui, env);
469
483
  if (!session) {
470
484
  return 1;
471
485
  }
@@ -473,25 +487,25 @@ async function handleWorkshopArchive(io, env, flags, deps) {
473
487
  try {
474
488
  const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
475
489
  const result = await client.archiveWorkshop(typeof flags.notes === "string" ? flags.notes : undefined);
476
- writeLine(io.stdout, JSON.stringify(result, null, 2));
490
+ ui.json("Workshop Archive", result);
477
491
  return 0;
478
492
  } catch (error) {
479
493
  if (error instanceof HarnessApiError) {
480
- writeLine(io.stderr, `Archive failed: ${error.message}`);
494
+ ui.status("error", `Archive failed: ${error.message}`, { stream: "stderr" });
481
495
  return 1;
482
496
  }
483
497
  throw error;
484
498
  }
485
499
  }
486
500
 
487
- async function handleWorkshopPhaseSet(io, env, positionals, deps) {
501
+ async function handleWorkshopPhaseSet(io, ui, env, positionals, deps) {
488
502
  const phaseId = positionals[3];
489
503
  if (!phaseId) {
490
- writeLine(io.stderr, "Phase id is required.");
504
+ ui.status("error", "Phase id is required.", { stream: "stderr" });
491
505
  return 1;
492
506
  }
493
507
 
494
- const session = await requireSession(io, env);
508
+ const session = await requireSession(io, ui, env);
495
509
  if (!session) {
496
510
  return 1;
497
511
  }
@@ -499,11 +513,11 @@ async function handleWorkshopPhaseSet(io, env, positionals, deps) {
499
513
  try {
500
514
  const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
501
515
  const result = await client.setCurrentPhase(phaseId);
502
- writeLine(io.stdout, JSON.stringify(result, null, 2));
516
+ ui.json("Workshop Phase", result);
503
517
  return 0;
504
518
  } catch (error) {
505
519
  if (error instanceof HarnessApiError) {
506
- writeLine(io.stderr, `Phase update failed: ${error.message}`);
520
+ ui.status("error", `Phase update failed: ${error.message}`, { stream: "stderr" });
507
521
  return 1;
508
522
  }
509
523
  throw error;
@@ -513,11 +527,12 @@ async function handleWorkshopPhaseSet(io, env, positionals, deps) {
513
527
  export async function runCli(argv, io, deps = {}) {
514
528
  const fetchFn = deps.fetchFn ?? globalThis.fetch;
515
529
  const mergedDeps = { fetchFn, sleepFn: deps.sleepFn, openUrl: deps.openUrl, cwd: deps.cwd };
530
+ const ui = createCliUi(io);
516
531
  const { positionals, flags } = parseArgs(argv);
517
532
  const [scope, action, subaction] = positionals;
518
533
 
519
534
  if (flags.help === true) {
520
- printUsage(io);
535
+ printUsage(io, ui);
521
536
  return 0;
522
537
  }
523
538
 
@@ -532,7 +547,7 @@ export async function runCli(argv, io, deps = {}) {
532
547
  }
533
548
 
534
549
  if (!scope) {
535
- printUsage(io);
550
+ printUsage(io, ui);
536
551
  return 1;
537
552
  }
538
553
 
@@ -541,33 +556,44 @@ export async function runCli(argv, io, deps = {}) {
541
556
  }
542
557
 
543
558
  if (scope === "auth" && action === "login") {
544
- return handleAuthLogin(io, io.env, flags, mergedDeps);
559
+ return handleAuthLogin(io, ui, io.env, flags, mergedDeps);
545
560
  }
546
561
 
547
562
  if (scope === "auth" && action === "logout") {
548
- return handleAuthLogout(io, io.env, mergedDeps);
563
+ return handleAuthLogout(io, ui, io.env, mergedDeps);
549
564
  }
550
565
 
551
566
  if (scope === "auth" && action === "status") {
552
- return handleAuthStatus(io, io.env, mergedDeps);
567
+ return handleAuthStatus(io, ui, io.env, mergedDeps);
553
568
  }
554
569
 
555
570
  if (scope === "skill" && action === "install") {
556
- return handleSkillInstall(io, mergedDeps, flags);
571
+ return handleSkillInstall(io, ui, mergedDeps, flags);
557
572
  }
558
573
 
559
574
  if (scope === "workshop" && action === "status") {
560
- return handleWorkshopStatus(io, io.env, mergedDeps);
575
+ return handleWorkshopStatus(io, ui, io.env, mergedDeps);
561
576
  }
562
577
 
563
578
  if (scope === "workshop" && action === "archive") {
564
- return handleWorkshopArchive(io, io.env, flags, mergedDeps);
579
+ return handleWorkshopArchive(io, ui, io.env, flags, mergedDeps);
565
580
  }
566
581
 
567
582
  if (scope === "workshop" && action === "phase" && subaction === "set") {
568
- return handleWorkshopPhaseSet(io, io.env, positionals, mergedDeps);
583
+ return handleWorkshopPhaseSet(io, ui, io.env, positionals, mergedDeps);
569
584
  }
570
585
 
571
- printUsage(io);
586
+ printUsage(io, ui);
572
587
  return 1;
573
588
  }
589
+
590
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
591
+ const exitCode = await runCli(process.argv.slice(2), {
592
+ stdin: process.stdin,
593
+ stdout: process.stdout,
594
+ stderr: process.stderr,
595
+ env: process.env,
596
+ });
597
+
598
+ process.exitCode = exitCode;
599
+ }
@@ -41,6 +41,10 @@ export function getInstalledSkillPath(repoRoot) {
41
41
  return path.join(repoRoot, ".agents", "skills", SKILL_NAME);
42
42
  }
43
43
 
44
+ async function hasBundledRepoSkill(repoRoot) {
45
+ return pathExists(path.join(getInstalledSkillPath(repoRoot), "SKILL.md"));
46
+ }
47
+
44
48
  export async function installWorkshopSkill(startDir, options = {}) {
45
49
  const repoRoot = await findHarnessLabRepoRoot(startDir);
46
50
  if (!repoRoot) {
@@ -51,6 +55,17 @@ export async function installWorkshopSkill(startDir, options = {}) {
51
55
  }
52
56
 
53
57
  const installPath = getInstalledSkillPath(repoRoot);
58
+ const bundledRepoSkill = await hasBundledRepoSkill(repoRoot);
59
+
60
+ if (bundledRepoSkill) {
61
+ return {
62
+ repoRoot,
63
+ installPath,
64
+ skillName: SKILL_NAME,
65
+ mode: "already_bundled",
66
+ };
67
+ }
68
+
54
69
  if ((await pathExists(installPath)) && options.force !== true) {
55
70
  throw new SkillInstallError(
56
71
  `Skill already installed at ${installPath}. Re-run with --force to replace it.`,
@@ -78,5 +93,10 @@ export async function installWorkshopSkill(startDir, options = {}) {
78
93
  path.join(installPath, "docs", "harness-cli-foundation.md"),
79
94
  );
80
95
 
81
- return { repoRoot, installPath, skillName: SKILL_NAME };
96
+ return {
97
+ repoRoot,
98
+ installPath,
99
+ skillName: SKILL_NAME,
100
+ mode: "installed",
101
+ };
82
102
  }