@formigio/fazemos-cli 0.6.0 → 0.7.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/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
- import { config, getEnv, getToken, getActiveOrgId, setActiveOrgId, addEnvironment, hasEnvironments } from './config.js';
4
+ import { config, getEnv, getToken, getActiveOrgId, setActiveOrgId, addEnvironment, hasEnvironments,
5
+ // F15 — project context helpers
6
+ getActiveProjectId, setActiveProjectId, clearActiveProjectId, findProjectBySlug, findProjectById, findOrgById, } from './config.js';
5
7
  import { login, signup, confirmSignup, adminLogin } from './auth.js';
6
- import { api, ApiError } from './api.js';
8
+ import { api, ApiError, refreshAuthMeCache, invalidateAuthMeCache } from './api.js';
7
9
  import { execSync } from 'child_process';
8
10
  import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
9
11
  import { fileURLToPath } from 'url';
@@ -249,13 +251,47 @@ auth
249
251
  // ── Whoami ──────────────────────────────────────────────────
250
252
  program
251
253
  .command('whoami')
252
- .description('Show current user and org context')
254
+ .description('Show current user, org, and project context')
253
255
  .action(async () => {
254
256
  try {
255
- const data = await api('GET', '/auth/me');
256
- console.log(` User: ${chalk.cyan(data.user.email)}`);
257
- console.log(` Member: ${data.member.displayName} (${data.member.role})`);
258
- console.log(` Org: ${data.member.orgId}`);
257
+ // Force-refresh the /auth/me cache on whoami so subsequent slug
258
+ // resolutions (e.g., `projects switch vahmos`) see the fresh state
259
+ // without a second round-trip.
260
+ const data = await refreshAuthMeCache();
261
+ if (!data) {
262
+ // Fall back to a direct call if refresh failed silently.
263
+ const direct = await api('GET', '/auth/me', undefined, { noProjectHeader: true });
264
+ console.log(` User: ${chalk.cyan(direct.user.email)}`);
265
+ console.log(` Member: ${direct.member.displayName} (${direct.member.role})`);
266
+ console.log(` Org: ${direct.member.orgId}`);
267
+ return;
268
+ }
269
+ console.log(` User: ${chalk.cyan(data.user.email)}`);
270
+ console.log(` Member: ${data.member.displayName} (${data.member.role})`);
271
+ const activeOrgId = getActiveOrgId() ?? data.activeOrgId;
272
+ const activeOrg = data.orgs.find(o => o.id === activeOrgId);
273
+ if (activeOrg) {
274
+ console.log(` Org: ${chalk.cyan(activeOrg.name)} (${activeOrg.slug})`);
275
+ }
276
+ else {
277
+ console.log(` Org: ${activeOrgId}`);
278
+ }
279
+ const activeProjectId = getActiveProjectId();
280
+ if (activeProjectId && activeOrg) {
281
+ const activeProject = activeOrg.projects.find(p => p.id === activeProjectId);
282
+ if (activeProject) {
283
+ console.log(` Project: ${chalk.cyan(activeProject.name)} (${activeProject.slug})`);
284
+ }
285
+ else {
286
+ console.log(` Project: ${activeProjectId}`);
287
+ }
288
+ }
289
+ else if (activeOrg && activeOrg.projects.length === 0) {
290
+ console.log(` Project: ${chalk.gray('(none — create one with: fazemos projects create <slug> --name <n>)')}`);
291
+ }
292
+ else {
293
+ console.log(` Project: ${chalk.gray('(none set — fazemos projects switch <slug>)')}`);
294
+ }
259
295
  }
260
296
  catch (err) {
261
297
  console.error(chalk.red(err.message));
@@ -269,7 +305,7 @@ orgs
269
305
  .description('List your organizations')
270
306
  .action(async () => {
271
307
  try {
272
- const data = await api('GET', '/api/organizations/mine');
308
+ const data = await api('GET', '/api/organizations/mine', undefined, { noProjectHeader: true });
273
309
  if (data.organizations.length === 0) {
274
310
  console.log(chalk.yellow('No organizations. Create one with: fazemos orgs create'));
275
311
  return;
@@ -313,11 +349,11 @@ orgs
313
349
  });
314
350
  orgs
315
351
  .command('switch')
316
- .description('Switch active organization')
352
+ .description('Switch active organization. Restores the last active project for this org from config, or clears it if none has been used here before.')
317
353
  .argument('<slug>', 'Organization slug')
318
354
  .action(async (slug) => {
319
355
  try {
320
- const data = await api('GET', '/api/organizations/mine');
356
+ const data = await api('GET', '/api/organizations/mine', undefined, { noProjectHeader: true });
321
357
  const org = data.organizations.find((o) => o.slug === slug);
322
358
  if (!org) {
323
359
  console.error(chalk.red(`No organization with slug "${slug}"`));
@@ -329,6 +365,36 @@ orgs
329
365
  }
330
366
  setActiveOrgId(org.id);
331
367
  console.log(chalk.green(`Switched to ${org.name} (${org.slug})`));
368
+ // F15 — refresh /auth/me so the next `fazemos projects list` (and any
369
+ // slug→id resolutions) see the project set for the new org. Without
370
+ // this the cached authMeCache is effectively stale and slug lookups
371
+ // force an extra network call each time.
372
+ invalidateAuthMeCache();
373
+ await refreshAuthMeCache();
374
+ // Report the restored active project, if any. Matches UX §1.8
375
+ // "Switching Org" — restore last-used project for this org or fall
376
+ // back to the first project alphabetically.
377
+ const activeProjectId = getActiveProjectId();
378
+ if (activeProjectId) {
379
+ const restored = findProjectById(org.id, activeProjectId);
380
+ if (restored) {
381
+ console.log(chalk.cyan(`Restored project: ${restored.name} (${restored.slug})`));
382
+ }
383
+ else {
384
+ // Stored project id is no longer in this org (archived?). Clear it.
385
+ clearActiveProjectId(org.id);
386
+ console.log(chalk.gray('Previously active project for this org is no longer available.'));
387
+ }
388
+ }
389
+ else {
390
+ const orgCache = findOrgById(org.id);
391
+ if (orgCache && orgCache.projects.length === 0) {
392
+ console.log(chalk.gray('No projects in this org yet. Create one with: fazemos projects create <slug> --name <n>'));
393
+ }
394
+ else if (orgCache && orgCache.projects.length > 0) {
395
+ console.log(chalk.gray(`No active project set. Run: fazemos projects switch <slug>`));
396
+ }
397
+ }
332
398
  }
333
399
  catch (err) {
334
400
  console.error(chalk.red(err.message));
@@ -848,15 +914,333 @@ notifications
848
914
  process.exit(1);
849
915
  }
850
916
  });
917
+ // ── F15 — Scoped-command helpers ────────────────────────────
918
+ // Shared helpers for commands whose API calls require a Project context.
919
+ // Keeps the two new flags (--project <slug>, --all-projects) and the
920
+ // uniform missing-project error rendering in one place so every scoped
921
+ // command uses the exact same shape.
922
+ /**
923
+ * Extract ApiOptions (projectSlug + allProjects) from a commander opts
924
+ * bag. Call this inside any `.action()` that forwards to `api()`.
925
+ */
926
+ function projectOpts(opts) {
927
+ return {
928
+ projectSlug: opts.project,
929
+ allProjects: opts.allProjects,
930
+ };
931
+ }
932
+ /**
933
+ * Uniform error handler for scoped commands. When the API emits
934
+ * MISSING_PROJECT_CONTEXT (§7.3.1), the api helper has already re-shaped
935
+ * the message to "requirement missing: project". This helper prints that
936
+ * as the error AND appends the standard three-option hint block per KD9.
937
+ *
938
+ * All other errors pass through with their original message (preserving
939
+ * PROJECT_NOT_FOUND, PROJECT_MISMATCH, VALIDATION_ERROR wording) — the
940
+ * CLI's job is to be a thin validator, not to re-author API errors.
941
+ */
942
+ function handleScopedError(err) {
943
+ if (err instanceof ApiError && err.code === 'MISSING_PROJECT_CONTEXT') {
944
+ console.error(chalk.red('Error: requirement missing: project'));
945
+ console.error('');
946
+ console.error(chalk.gray('Set one with: fazemos projects switch <slug>'));
947
+ console.error(chalk.gray('Or pass: --project <slug>'));
948
+ console.error(chalk.gray('Or view all: --all-projects'));
949
+ process.exit(1);
950
+ }
951
+ if (err instanceof Error) {
952
+ console.error(chalk.red(err.message));
953
+ }
954
+ else {
955
+ console.error(chalk.red(String(err)));
956
+ }
957
+ process.exit(1);
958
+ }
959
+ // ── F15 — Projects ──────────────────────────────────────────
960
+ const projects = program.command('projects').alias('proj').description('Project commands');
961
+ projects
962
+ .command('list')
963
+ .description('List projects in the active organization')
964
+ .option('-s, --status <status>', 'Filter by status: active (default), archived, or all', 'active')
965
+ .action(async (opts) => {
966
+ try {
967
+ const orgId = requireActiveOrgOrExit();
968
+ const data = await api('GET', `/api/organizations/${orgId}/projects?status=${encodeURIComponent(opts.status)}`, undefined, { noProjectHeader: true });
969
+ if (!data.projects || data.projects.length === 0) {
970
+ if (opts.status === 'active') {
971
+ console.log(chalk.yellow('No active projects in this organization.'));
972
+ console.log(chalk.gray('Create one with: fazemos projects create <slug> --name <name>'));
973
+ }
974
+ else {
975
+ console.log(chalk.yellow(`No ${opts.status} projects.`));
976
+ }
977
+ return;
978
+ }
979
+ const activeProjectId = getActiveProjectId();
980
+ for (const p of data.projects) {
981
+ const active = p.id === activeProjectId ? chalk.green(' ✓') : '';
982
+ const status = p.status === 'archived' ? chalk.gray(' [archived]') : '';
983
+ const activity = p.lastActivityAt
984
+ ? chalk.gray(` — last active ${formatRelative(new Date(p.lastActivityAt))}`)
985
+ : '';
986
+ console.log(` ${chalk.cyan(p.name)} (${p.slug})${active}${status}${activity}`);
987
+ if (p.stats) {
988
+ const stats = [];
989
+ if (p.stats.activePipelines)
990
+ stats.push(`${p.stats.activePipelines} pipelines`);
991
+ if (p.stats.worksheets)
992
+ stats.push(`${p.stats.worksheets} worksheets`);
993
+ if (p.stats.templates)
994
+ stats.push(`${p.stats.templates} templates`);
995
+ if (stats.length)
996
+ console.log(chalk.gray(` ${stats.join(' · ')}`));
997
+ }
998
+ }
999
+ }
1000
+ catch (err) {
1001
+ console.error(chalk.red(err.message));
1002
+ process.exit(1);
1003
+ }
1004
+ });
1005
+ projects
1006
+ .command('create')
1007
+ .description('Create a new project in the active organization. Owner/admin only. Slug is immutable after creation — pick carefully.')
1008
+ .argument('<slug>', 'URL slug (lowercase letters, numbers, hyphens; 1-32 chars). Used in URLs and CLI config — cannot be changed.')
1009
+ .requiredOption('-n, --name <name>', 'Human-readable name (max 200 chars)')
1010
+ .option('-d, --description <desc>', 'Optional description (max 2000 chars)')
1011
+ .action(async (slug, opts) => {
1012
+ try {
1013
+ const orgId = requireActiveOrgOrExit();
1014
+ const body = {
1015
+ slug,
1016
+ name: opts.name,
1017
+ };
1018
+ if (opts.description)
1019
+ body.description = opts.description;
1020
+ const data = await api('POST', `/api/organizations/${orgId}/projects`, body, { noProjectHeader: true });
1021
+ const p = data.project;
1022
+ console.log(chalk.green(`Created project: ${p.name} (${p.slug})`));
1023
+ console.log(` ID: ${p.id}`);
1024
+ console.log(` Org: ${orgId}`);
1025
+ if (p.description)
1026
+ console.log(` Desc: ${p.description}`);
1027
+ // Invalidate cached /auth/me and set as active — a fresh project is
1028
+ // usually created immediately before scoped work on it, so auto-
1029
+ // switching saves an extra command. Matches the spec's post-create
1030
+ // success path (UX §2.1 toast "You're in.").
1031
+ invalidateAuthMeCache();
1032
+ setActiveProjectId(orgId, p.id);
1033
+ console.log(chalk.cyan(`Switched active project to ${p.slug}.`));
1034
+ }
1035
+ catch (err) {
1036
+ if (err instanceof ApiError && err.code === 'SLUG_CONFLICT') {
1037
+ console.error(chalk.red(`Error: "${slug}" is already taken in this organization.`));
1038
+ console.log(chalk.gray('Pick a different slug or use: fazemos projects list'));
1039
+ process.exit(1);
1040
+ }
1041
+ console.error(chalk.red(err.message));
1042
+ process.exit(1);
1043
+ }
1044
+ });
1045
+ projects
1046
+ .command('show')
1047
+ .description('Show project details by slug. Resolves against the active organization.')
1048
+ .argument('<slug>', 'Project slug')
1049
+ .action(async (slug) => {
1050
+ try {
1051
+ const orgId = requireActiveOrgOrExit();
1052
+ // Resolve slug -> id via cached /auth/me; refresh once on miss.
1053
+ let project = findProjectBySlug(orgId, slug);
1054
+ if (!project) {
1055
+ await refreshAuthMeCache();
1056
+ project = findProjectBySlug(orgId, slug);
1057
+ }
1058
+ if (!project) {
1059
+ console.error(chalk.red(`Unknown project: ${slug}`));
1060
+ console.log(chalk.gray('Run: fazemos projects list'));
1061
+ process.exit(1);
1062
+ }
1063
+ const data = await api('GET', `/api/organizations/${orgId}/projects/${project.id}`, undefined, { noProjectHeader: true });
1064
+ const p = data.project;
1065
+ console.log(chalk.cyan(p.name));
1066
+ console.log(` Slug: ${p.slug}`);
1067
+ console.log(` ID: ${p.id}`);
1068
+ console.log(` Status: ${p.status === 'archived' ? chalk.gray('archived') : chalk.green('active')}`);
1069
+ if (p.description)
1070
+ console.log(` Description: ${p.description}`);
1071
+ console.log(` Created: ${p.createdAt ? new Date(p.createdAt).toLocaleString() : '(unknown)'}`);
1072
+ if (p.archivedAt)
1073
+ console.log(` Archived at: ${new Date(p.archivedAt).toLocaleString()}`);
1074
+ if (p.stats) {
1075
+ console.log('');
1076
+ console.log(chalk.cyan('Activity:'));
1077
+ console.log(` Active pipelines: ${p.stats.activePipelines ?? 0}`);
1078
+ console.log(` Worksheets: ${p.stats.worksheets ?? 0}`);
1079
+ console.log(` Templates: ${p.stats.templates ?? 0}`);
1080
+ }
1081
+ }
1082
+ catch (err) {
1083
+ console.error(chalk.red(err.message));
1084
+ process.exit(1);
1085
+ }
1086
+ });
1087
+ projects
1088
+ .command('switch')
1089
+ .description('Switch active project. Persists in ~/.fazemos/config.json as the active project for the current org.')
1090
+ .argument('<slug>', 'Project slug')
1091
+ .action(async (slug) => {
1092
+ try {
1093
+ const orgId = requireActiveOrgOrExit();
1094
+ let project = findProjectBySlug(orgId, slug);
1095
+ if (!project) {
1096
+ // Slug-miss cache refresh per §8.3.
1097
+ await refreshAuthMeCache();
1098
+ project = findProjectBySlug(orgId, slug);
1099
+ }
1100
+ if (!project) {
1101
+ console.error(chalk.red(`Unknown project: ${slug}`));
1102
+ console.log(chalk.gray('Run: fazemos projects list'));
1103
+ process.exit(1);
1104
+ }
1105
+ setActiveProjectId(orgId, project.id);
1106
+ const org = findOrgById(orgId);
1107
+ const orgName = org?.name ?? orgId;
1108
+ console.log(chalk.green(`Switched to project: ${project.name} (${project.slug}) in ${orgName}`));
1109
+ }
1110
+ catch (err) {
1111
+ console.error(chalk.red(err.message));
1112
+ process.exit(1);
1113
+ }
1114
+ });
1115
+ projects
1116
+ .command('archive')
1117
+ .description('Archive a project. Puts it in read-only mode; existing work is preserved. Owner/admin only. Blocked if any pipeline is currently running — wait for it to finish or cancel it first.')
1118
+ .argument('<slug>', 'Project slug')
1119
+ .action(async (slug) => {
1120
+ try {
1121
+ const orgId = requireActiveOrgOrExit();
1122
+ let project = findProjectBySlug(orgId, slug);
1123
+ if (!project) {
1124
+ await refreshAuthMeCache();
1125
+ project = findProjectBySlug(orgId, slug);
1126
+ }
1127
+ if (!project) {
1128
+ console.error(chalk.red(`Unknown project: ${slug}`));
1129
+ process.exit(1);
1130
+ }
1131
+ const data = await api('POST', `/api/organizations/${orgId}/projects/${project.id}/archive`, {}, { noProjectHeader: true });
1132
+ console.log(chalk.green(`Archived: ${data.project.name} (${data.project.slug})`));
1133
+ invalidateAuthMeCache();
1134
+ // If this was the active project, clear it — subsequent scoped
1135
+ // commands will emit "requirement missing: project" per KD9.
1136
+ if (getActiveProjectId() === project.id) {
1137
+ clearActiveProjectId(orgId);
1138
+ console.log(chalk.cyan('Active project cleared. Run: fazemos projects switch <slug>'));
1139
+ }
1140
+ }
1141
+ catch (err) {
1142
+ if (err instanceof ApiError && err.code === 'PROJECT_HAS_RUNNING_PIPELINES') {
1143
+ console.error(chalk.red(`Can't archive ${slug} yet — pipelines are still running.`));
1144
+ const body = err.body;
1145
+ if (body?.runningPipelines?.length) {
1146
+ console.log('');
1147
+ console.log(chalk.yellow('Running pipelines:'));
1148
+ for (const p of body.runningPipelines) {
1149
+ console.log(` · ${p.name} (${p.id})`);
1150
+ }
1151
+ }
1152
+ console.log('');
1153
+ console.log(chalk.gray('Wait for them to finish or cancel them, then retry.'));
1154
+ process.exit(1);
1155
+ }
1156
+ console.error(chalk.red(err.message));
1157
+ process.exit(1);
1158
+ }
1159
+ });
1160
+ projects
1161
+ .command('unarchive')
1162
+ .description('Restore an archived project to active status. Owner/admin only.')
1163
+ .argument('<slug>', 'Project slug (must be archived)')
1164
+ .action(async (slug) => {
1165
+ try {
1166
+ const orgId = requireActiveOrgOrExit();
1167
+ // Archived projects aren't in the default /auth/me response, so we
1168
+ // can't always resolve via cache. Fall back to the full-status list
1169
+ // endpoint to find the id.
1170
+ let project = findProjectBySlug(orgId, slug);
1171
+ if (!project) {
1172
+ const listData = await api('GET', `/api/organizations/${orgId}/projects?status=all`, undefined, { noProjectHeader: true });
1173
+ const match = (listData.projects ?? []).find((p) => p.slug === slug);
1174
+ if (match) {
1175
+ project = {
1176
+ id: match.id,
1177
+ slug: match.slug,
1178
+ name: match.name,
1179
+ colorIndex: match.colorIndex ?? 0,
1180
+ status: match.status,
1181
+ };
1182
+ }
1183
+ }
1184
+ if (!project) {
1185
+ console.error(chalk.red(`Unknown project: ${slug}`));
1186
+ process.exit(1);
1187
+ }
1188
+ const data = await api('POST', `/api/organizations/${orgId}/projects/${project.id}/unarchive`, {}, { noProjectHeader: true });
1189
+ console.log(chalk.green(`Unarchived: ${data.project.name} (${data.project.slug})`));
1190
+ invalidateAuthMeCache();
1191
+ }
1192
+ catch (err) {
1193
+ console.error(chalk.red(err.message));
1194
+ process.exit(1);
1195
+ }
1196
+ });
1197
+ projects
1198
+ .command('update')
1199
+ .description('Update project name or description. Slug is immutable — to use a different slug, create a new project. Owner/admin only.')
1200
+ .argument('<slug>', 'Project slug')
1201
+ .option('-n, --name <name>', 'New name')
1202
+ .option('-d, --description <desc>', 'New description (pass empty string to clear)')
1203
+ .action(async (slug, opts) => {
1204
+ try {
1205
+ if (!opts.name && opts.description == null) {
1206
+ console.error(chalk.red('Provide --name and/or --description'));
1207
+ process.exit(1);
1208
+ }
1209
+ const orgId = requireActiveOrgOrExit();
1210
+ let project = findProjectBySlug(orgId, slug);
1211
+ if (!project) {
1212
+ await refreshAuthMeCache();
1213
+ project = findProjectBySlug(orgId, slug);
1214
+ }
1215
+ if (!project) {
1216
+ console.error(chalk.red(`Unknown project: ${slug}`));
1217
+ process.exit(1);
1218
+ }
1219
+ const body = {};
1220
+ if (opts.name)
1221
+ body.name = opts.name;
1222
+ if (opts.description != null)
1223
+ body.description = opts.description;
1224
+ const data = await api('PATCH', `/api/organizations/${orgId}/projects/${project.id}`, body, { noProjectHeader: true });
1225
+ console.log(chalk.green(`Updated: ${data.project.name} (${data.project.slug})`));
1226
+ invalidateAuthMeCache();
1227
+ }
1228
+ catch (err) {
1229
+ console.error(chalk.red(err.message));
1230
+ process.exit(1);
1231
+ }
1232
+ });
851
1233
  // ── Worksheets ──────────────────────────────────────────────
852
1234
  const ws = program.command('worksheets').alias('ws').description('Worksheet commands');
853
1235
  ws
854
1236
  .command('list')
855
- .description('List worksheets')
1237
+ .description('List worksheets in the active project (or all projects with --all-projects)')
856
1238
  .option('-s, --status <status>', 'Filter by status', 'active')
1239
+ .option('--project <slug>', 'Override active project for this call')
1240
+ .option('--all-projects', 'List worksheets across every project in the active org', false)
857
1241
  .action(async (opts) => {
858
1242
  try {
859
- const data = await api('GET', `/api/worksheets?status=${opts.status}`);
1243
+ const data = await api('GET', `/api/worksheets?status=${opts.status}`, undefined, projectOpts(opts));
860
1244
  if (data.worksheets.length === 0) {
861
1245
  console.log(chalk.yellow('No worksheets'));
862
1246
  return;
@@ -866,16 +1250,16 @@ ws
866
1250
  }
867
1251
  }
868
1252
  catch (err) {
869
- console.error(chalk.red(err.message));
870
- process.exit(1);
1253
+ handleScopedError(err);
871
1254
  }
872
1255
  });
873
1256
  ws
874
1257
  .command('create')
875
- .description('Create a worksheet')
1258
+ .description('Create a worksheet in the active project')
876
1259
  .requiredOption('-n, --name <name>', 'Worksheet name')
877
1260
  .option('-p, --purpose <purpose>', 'Purpose (e.g., "From X to Y by When")')
878
1261
  .option('--cadence <cadence>', 'Check-in cadence (weekly, biweekly, monthly)', 'weekly')
1262
+ .option('--project <slug>', 'Override active project for this call')
879
1263
  .action(async (opts) => {
880
1264
  try {
881
1265
  const body = { name: opts.name };
@@ -883,7 +1267,7 @@ ws
883
1267
  body.purpose = opts.purpose;
884
1268
  if (opts.cadence)
885
1269
  body.checkInCadence = opts.cadence;
886
- const data = await api('POST', '/api/worksheets', body);
1270
+ const data = await api('POST', '/api/worksheets', body, projectOpts(opts));
887
1271
  const w = data.worksheet;
888
1272
  console.log(chalk.green(`Created: ${w.name}`));
889
1273
  console.log(` ID: ${w.id}`);
@@ -891,8 +1275,7 @@ ws
891
1275
  console.log(` Cadence: ${w.check_in_cadence}`);
892
1276
  }
893
1277
  catch (err) {
894
- console.error(chalk.red(err.message));
895
- process.exit(1);
1278
+ handleScopedError(err);
896
1279
  }
897
1280
  });
898
1281
  ws
@@ -1960,12 +2343,14 @@ const templates = program.command('templates').alias('tpl').description('Pipelin
1960
2343
  ' Use "tpl show <id>" to see full structure with I/O declarations.');
1961
2344
  templates
1962
2345
  .command('list')
1963
- .description('List pipeline templates')
2346
+ .description('List pipeline templates in the active project (or all projects with --all-projects)')
1964
2347
  .option('-s, --status <status>', 'Filter by status (draft, active, archived)')
2348
+ .option('--project <slug>', 'Override active project for this call')
2349
+ .option('--all-projects', 'List templates across every project in the active org', false)
1965
2350
  .action(async (opts) => {
1966
2351
  try {
1967
2352
  const qs = opts.status ? `?status=${opts.status}` : '';
1968
- const data = await api('GET', `/api/pipeline-templates${qs}`);
2353
+ const data = await api('GET', `/api/pipeline-templates${qs}`, undefined, projectOpts(opts));
1969
2354
  if (!data.templates?.length) {
1970
2355
  console.log(chalk.yellow('No templates'));
1971
2356
  return;
@@ -1977,8 +2362,7 @@ templates
1977
2362
  }
1978
2363
  }
1979
2364
  catch (err) {
1980
- console.error(chalk.red(err.message));
1981
- process.exit(1);
2365
+ handleScopedError(err);
1982
2366
  }
1983
2367
  });
1984
2368
  templates
@@ -3086,10 +3470,12 @@ templates
3086
3470
  const pipelines = program.command('pipelines').alias('pl').description('Pipeline instance commands');
3087
3471
  pipelines
3088
3472
  .command('list')
3089
- .description('List pipeline instances')
3473
+ .description('List pipeline instances in the active project (or all projects with --all-projects)')
3090
3474
  .option('-s, --status <status>', 'Filter by status (active, completed, archived)', 'active')
3091
3475
  .option('--search <term>', 'Search by name or ID')
3092
3476
  .option('--expand', 'Include steps inline (avoids N+1)')
3477
+ .option('--project <slug>', 'Override active project for this call')
3478
+ .option('--all-projects', 'List pipelines across every project in the active org', false)
3093
3479
  .action(async (opts) => {
3094
3480
  try {
3095
3481
  const params = [];
@@ -3100,7 +3486,7 @@ pipelines
3100
3486
  if (opts.expand)
3101
3487
  params.push('expand=steps');
3102
3488
  const qs = params.length ? `?${params.join('&')}` : '';
3103
- const data = await api('GET', `/api/pipeline-instances${qs}`);
3489
+ const data = await api('GET', `/api/pipeline-instances${qs}`, undefined, projectOpts(opts));
3104
3490
  if (!data.instances?.length) {
3105
3491
  console.log(chalk.yellow('No pipeline instances'));
3106
3492
  return;
@@ -3121,8 +3507,7 @@ pipelines
3121
3507
  }
3122
3508
  }
3123
3509
  catch (err) {
3124
- console.error(chalk.red(err.message));
3125
- process.exit(1);
3510
+ handleScopedError(err);
3126
3511
  }
3127
3512
  });
3128
3513
  pipelines
@@ -3568,14 +3953,57 @@ step
3568
3953
  process.exit(1);
3569
3954
  }
3570
3955
  });
3956
+ step
3957
+ .command('reset')
3958
+ .description('Reset a completed step back to queued with feedback. The step must be in "completed" status. Feedback is required — it tells the agent or human what went wrong and what to fix. By default, all downstream steps that consumed this step\'s output are cascade-reset to blocked. Use --no-cascade to skip this (only when the fix doesn\'t affect downstream consumers). Use "pl show <id>" to find step IDs and statuses.')
3959
+ .argument('<instanceId>', 'Pipeline instance ID')
3960
+ .argument('<stepId>', 'Step instance ID')
3961
+ .requiredOption('-f, --feedback <text>', 'What the step got wrong and what to fix (required)')
3962
+ .option('--no-cascade', 'Skip resetting downstream steps')
3963
+ .action(async (instanceId, stepId, opts) => {
3964
+ try {
3965
+ const data = await api('POST', `/api/pipeline-instances/${instanceId}/steps/${stepId}/reset`, {
3966
+ feedback: opts.feedback,
3967
+ cascade: opts.cascade,
3968
+ });
3969
+ const stepName = data.step?.step_name || stepId;
3970
+ const status = data.step?.status || 'unknown';
3971
+ console.log(chalk.green(`Step reset: "${stepName}" (${status})`));
3972
+ // Truncate feedback to 120 chars for display
3973
+ const fb = opts.feedback.length > 120 ? opts.feedback.slice(0, 120) + '...' : opts.feedback;
3974
+ console.log(` Feedback: "${fb}"`);
3975
+ // Cascade summary
3976
+ const cascadeResult = data.cascade_result;
3977
+ if (!opts.cascade) {
3978
+ console.log(` Cascade: disabled (downstream steps unchanged)`);
3979
+ }
3980
+ else if (!cascadeResult || cascadeResult.blocked_count === 0) {
3981
+ console.log(` Cascade: no downstream steps to block`);
3982
+ }
3983
+ else {
3984
+ console.log(` Cascade: ${cascadeResult.blocked_count} downstream step${cascadeResult.blocked_count === 1 ? '' : 's'} blocked`);
3985
+ if (cascadeResult.blocked_steps?.length) {
3986
+ for (const bs of cascadeResult.blocked_steps) {
3987
+ console.log(` - ${bs.step_name} (was: ${bs.previous_status})`);
3988
+ }
3989
+ }
3990
+ }
3991
+ }
3992
+ catch (err) {
3993
+ console.error(chalk.red(err.message));
3994
+ process.exit(1);
3995
+ }
3996
+ });
3571
3997
  // ── Queue ──────────────────────────────────────────────────
3572
3998
  const queue = program.command('queue').alias('q').description('Work queue commands');
3573
3999
  queue
3574
4000
  .command('summary')
3575
- .description('Show queue depth per agent')
3576
- .action(async () => {
4001
+ .description('Show queue depth per agent, scoped to the active project (or all projects with --all-projects)')
4002
+ .option('--project <slug>', 'Override active project for this call')
4003
+ .option('--all-projects', 'Show queue across every project in the active org', false)
4004
+ .action(async (opts) => {
3577
4005
  try {
3578
- const data = await api('GET', '/api/work-queue/summary');
4006
+ const data = await api('GET', '/api/work-queue/summary', undefined, projectOpts(opts));
3579
4007
  const entries = Object.entries(data.depths ?? {});
3580
4008
  if (entries.length === 0) {
3581
4009
  console.log(chalk.yellow('No agents in queue'));
@@ -3590,15 +4018,16 @@ queue
3590
4018
  console.log(` ${'Total'.padEnd(12)} ${total} steps`);
3591
4019
  }
3592
4020
  catch (err) {
3593
- console.error(chalk.red(err.message));
3594
- process.exit(1);
4021
+ handleScopedError(err);
3595
4022
  }
3596
4023
  });
3597
4024
  queue
3598
4025
  .command('list')
3599
- .description('List queued and in-progress steps')
4026
+ .description('List queued and in-progress steps in the active project (or all projects with --all-projects)')
3600
4027
  .option('-a, --agent <name>', 'Filter by agent name')
3601
4028
  .option('--message-id <id>', 'Filter by JOE messageId in step metadata')
4029
+ .option('--project <slug>', 'Override active project for this call')
4030
+ .option('--all-projects', 'List queue items across every project in the active org', false)
3602
4031
  .action(async (opts) => {
3603
4032
  try {
3604
4033
  const params = [];
@@ -3607,7 +4036,7 @@ queue
3607
4036
  if (opts.messageId)
3608
4037
  params.push(`messageId=${encodeURIComponent(opts.messageId)}`);
3609
4038
  const qs = params.length ? `?${params.join('&')}` : '';
3610
- const data = await api('GET', `/api/work-queue${qs}`);
4039
+ const data = await api('GET', `/api/work-queue${qs}`, undefined, projectOpts(opts));
3611
4040
  if (!data.items?.length) {
3612
4041
  console.log(chalk.yellow('No queued steps'));
3613
4042
  return;
@@ -3619,8 +4048,7 @@ queue
3619
4048
  }
3620
4049
  }
3621
4050
  catch (err) {
3622
- console.error(chalk.red(err.message));
3623
- process.exit(1);
4051
+ handleScopedError(err);
3624
4052
  }
3625
4053
  });
3626
4054
  // ── Executions ─────────────────────────────────────────────
@@ -4114,10 +4542,12 @@ executions
4114
4542
  // ── My Work ─────────────────────────────────────────────────
4115
4543
  program
4116
4544
  .command('my-work')
4117
- .description('Show pending work across all worksheets')
4118
- .action(async () => {
4545
+ .description('Show pending work in the active project (or all projects with --all-projects)')
4546
+ .option('--project <slug>', 'Override active project for this call')
4547
+ .option('--all-projects', 'Show pending work across every project in the active org', false)
4548
+ .action(async (opts) => {
4119
4549
  try {
4120
- const data = await api('GET', '/api/my-work');
4550
+ const data = await api('GET', '/api/my-work', undefined, projectOpts(opts));
4121
4551
  const c = data.commitments;
4122
4552
  const total = c.overdue.length + c.due_today.length + c.due_this_week.length + c.upcoming.length;
4123
4553
  console.log(chalk.cyan(`Commitments: ${total}`));
@@ -4134,8 +4564,7 @@ program
4134
4564
  console.log(chalk.cyan(`Pipeline steps: ${data.pipeline_steps.length}`));
4135
4565
  }
4136
4566
  catch (err) {
4137
- console.error(chalk.red(err.message));
4138
- process.exit(1);
4567
+ handleScopedError(err);
4139
4568
  }
4140
4569
  });
4141
4570
  // ── Test ────────────────────────────────────────────────────