@getjack/jack 0.1.3 → 0.1.5

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.
Files changed (55) hide show
  1. package/README.md +103 -0
  2. package/package.json +2 -6
  3. package/src/commands/agents.ts +9 -24
  4. package/src/commands/clone.ts +27 -0
  5. package/src/commands/down.ts +31 -57
  6. package/src/commands/feedback.ts +4 -5
  7. package/src/commands/link.ts +147 -0
  8. package/src/commands/logs.ts +8 -18
  9. package/src/commands/new.ts +7 -1
  10. package/src/commands/projects.ts +162 -105
  11. package/src/commands/secrets.ts +7 -6
  12. package/src/commands/services.ts +5 -4
  13. package/src/commands/tag.ts +282 -0
  14. package/src/commands/unlink.ts +30 -0
  15. package/src/index.ts +46 -1
  16. package/src/lib/auth/index.ts +2 -0
  17. package/src/lib/auth/store.ts +26 -2
  18. package/src/lib/binding-validator.ts +4 -13
  19. package/src/lib/build-helper.ts +93 -5
  20. package/src/lib/control-plane.ts +48 -0
  21. package/src/lib/deploy-mode.ts +1 -1
  22. package/src/lib/managed-deploy.ts +11 -1
  23. package/src/lib/managed-down.ts +7 -20
  24. package/src/lib/paths-index.test.ts +546 -0
  25. package/src/lib/paths-index.ts +310 -0
  26. package/src/lib/project-link.test.ts +459 -0
  27. package/src/lib/project-link.ts +279 -0
  28. package/src/lib/project-list.test.ts +581 -0
  29. package/src/lib/project-list.ts +445 -0
  30. package/src/lib/project-operations.ts +304 -183
  31. package/src/lib/project-resolver.ts +191 -211
  32. package/src/lib/tags.ts +389 -0
  33. package/src/lib/telemetry.ts +81 -168
  34. package/src/lib/zip-packager.ts +9 -0
  35. package/src/templates/index.ts +5 -3
  36. package/templates/api/.jack/template.json +4 -0
  37. package/templates/hello/.jack/template.json +4 -0
  38. package/templates/miniapp/.jack/template.json +4 -0
  39. package/templates/nextjs/.jack.json +28 -0
  40. package/templates/nextjs/app/globals.css +9 -0
  41. package/templates/nextjs/app/isr-test/page.tsx +22 -0
  42. package/templates/nextjs/app/layout.tsx +19 -0
  43. package/templates/nextjs/app/page.tsx +8 -0
  44. package/templates/nextjs/bun.lock +2232 -0
  45. package/templates/nextjs/cloudflare-env.d.ts +3 -0
  46. package/templates/nextjs/next-env.d.ts +6 -0
  47. package/templates/nextjs/next.config.ts +8 -0
  48. package/templates/nextjs/open-next.config.ts +6 -0
  49. package/templates/nextjs/package.json +24 -0
  50. package/templates/nextjs/public/_headers +2 -0
  51. package/templates/nextjs/tsconfig.json +44 -0
  52. package/templates/nextjs/wrangler.jsonc +17 -0
  53. package/src/lib/local-paths.test.ts +0 -902
  54. package/src/lib/local-paths.ts +0 -258
  55. package/src/lib/registry.ts +0 -181
@@ -1,8 +1,23 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, resolve } from "node:path";
3
+ import { resolve } from "node:path";
4
4
  import { promptSelect } from "../lib/hooks.ts";
5
5
  import { error, info, item, output as outputSpinner, success, warn } from "../lib/output.ts";
6
+ import {
7
+ type ProjectListItem,
8
+ STATUS_ICONS,
9
+ buildTagColorMap,
10
+ colors,
11
+ filterByStatus,
12
+ filterByTag,
13
+ formatCloudSection,
14
+ formatErrorSection,
15
+ formatLocalSection,
16
+ formatTagsInline,
17
+ groupProjects,
18
+ sortByUpdated,
19
+ toListItems,
20
+ } from "../lib/project-list.ts";
6
21
  import {
7
22
  cleanupStaleProjects,
8
23
  getProjectStatus,
@@ -51,133 +66,177 @@ export default async function projects(subcommand?: string, args: string[] = [])
51
66
  }
52
67
  }
53
68
 
69
+ /**
70
+ * Extract a flag value from args (e.g., --status live -> "live")
71
+ */
72
+ function extractFlagValue(args: string[], flag: string): string | null {
73
+ const idx = args.indexOf(flag);
74
+ if (idx !== -1 && idx + 1 < args.length) {
75
+ return args[idx + 1] ?? null;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Extract multiple flag values from args (e.g., --tag api --tag prod -> ["api", "prod"])
82
+ */
83
+ function extractFlagValues(args: string[], flag: string): string[] {
84
+ const values: string[] = [];
85
+ for (let i = 0; i < args.length; i++) {
86
+ if (args[i] === flag && i + 1 < args.length) {
87
+ const value = args[i + 1];
88
+ if (value) values.push(value);
89
+ }
90
+ }
91
+ return values;
92
+ }
93
+
54
94
  /**
55
95
  * List all projects with status indicators
56
96
  */
57
- async function listProjects(_args: string[]): Promise<void> {
97
+ async function listProjects(args: string[]): Promise<void> {
98
+ // Parse flags
99
+ const showAll = args.includes("--all") || args.includes("-a");
100
+ const statusFilter = extractFlagValue(args, "--status");
101
+ const tagFilters = extractFlagValues(args, "--tag");
102
+ const jsonOutput = args.includes("--json");
103
+ const localOnly = args.includes("--local");
104
+ const cloudOnly = args.includes("--cloud");
105
+
58
106
  // Fetch all projects from registry and control plane
59
107
  outputSpinner.start("Checking project status...");
60
108
  const projects: ResolvedProject[] = await listAllProjects();
61
109
  outputSpinner.stop();
62
110
 
63
- if (projects.length === 0) {
64
- info("No projects found");
65
- info("Create a project with: jack new <name>");
66
- return;
67
- }
111
+ // Convert to list items
112
+ let items = toListItems(projects);
68
113
 
69
- // Separate local projects from cloud-only projects
70
- const localProjects: ResolvedProject[] = [];
71
- const cloudOnlyProjects: ResolvedProject[] = [];
114
+ // Apply filters
115
+ if (statusFilter) items = filterByStatus(items, statusFilter);
116
+ if (localOnly) items = items.filter((i) => i.isLocal);
117
+ if (cloudOnly) items = items.filter((i) => i.isCloudOnly);
118
+ if (tagFilters.length > 0) items = filterByTag(items, tagFilters);
72
119
 
73
- for (const proj of projects) {
74
- if (proj.localPath && proj.sources.filesystem) {
75
- localProjects.push(proj);
120
+ // Handle empty state
121
+ if (items.length === 0) {
122
+ if (jsonOutput) {
123
+ console.log("[]");
124
+ return;
125
+ }
126
+ info("No projects found");
127
+ if (statusFilter || localOnly || cloudOnly || tagFilters.length > 0) {
128
+ info("Try removing filters to see all projects");
76
129
  } else {
77
- cloudOnlyProjects.push(proj);
130
+ info("Create a project with: jack new <name>");
78
131
  }
132
+ return;
79
133
  }
80
134
 
81
- // Group local projects by parent directory
82
- interface DirectoryGroup {
83
- displayPath: string;
84
- projects: ResolvedProject[];
135
+ // JSON output to stdout (pipeable)
136
+ if (jsonOutput) {
137
+ console.log(JSON.stringify(items, null, 2));
138
+ return;
85
139
  }
86
140
 
87
- const groups = new Map<string, DirectoryGroup>();
88
- const home = homedir();
89
-
90
- for (const proj of localProjects) {
91
- if (!proj.localPath) continue;
92
- const parent = dirname(proj.localPath);
93
- if (!groups.has(parent)) {
94
- // Replace home directory with ~ for display
95
- const displayPath = parent.startsWith(home) ? `~${parent.slice(home.length)}` : parent;
96
- groups.set(parent, { displayPath, projects: [] });
97
- }
98
- groups.get(parent)?.projects.push(proj);
141
+ // Flat table for --all mode
142
+ if (showAll) {
143
+ renderFlatTable(items);
144
+ return;
99
145
  }
100
146
 
101
- // Display header
102
- console.error("");
103
- info("Your projects");
104
- console.error("");
147
+ // Default: grouped view
148
+ renderGroupedView(items);
149
+ }
105
150
 
106
- // Display local project groups
107
- for (const [_parentPath, group] of groups) {
108
- console.error(` ${colors.dim}${group.displayPath}/${colors.reset}`);
109
- const sortedProjects = group.projects.sort((a, b) => a.name.localeCompare(b.name));
151
+ /**
152
+ * Render the grouped view (default)
153
+ */
154
+ function renderGroupedView(items: ProjectListItem[]): void {
155
+ const groups = groupProjects(items);
156
+ const total = items.length;
110
157
 
111
- for (let i = 0; i < sortedProjects.length; i++) {
112
- const proj = sortedProjects[i];
113
- if (!proj) continue;
114
- const isLast = i === sortedProjects.length - 1;
115
- const prefix = isLast ? "└──" : "├──";
158
+ // Build consistent tag color map across all projects
159
+ const tagColorMap = buildTagColorMap(items);
116
160
 
117
- const statusBadge = buildStatusBadge(proj);
118
- console.error(` ${colors.dim}${prefix}${colors.reset} ${proj.name} ${statusBadge}`);
119
- }
161
+ console.error("");
162
+ info(`${total} projects`);
163
+
164
+ // Section 1: Errors (always show all)
165
+ if (groups.errors.length > 0) {
120
166
  console.error("");
167
+ console.error(formatErrorSection(groups.errors, { tagColorMap }));
121
168
  }
122
169
 
123
- // Display cloud-only projects
124
- if (cloudOnlyProjects.length > 0) {
125
- console.error(` ${colors.dim}On jack cloud (no local files)${colors.reset}`);
126
- const sortedCloudProjects = cloudOnlyProjects.sort((a, b) => a.name.localeCompare(b.name));
127
-
128
- for (let i = 0; i < sortedCloudProjects.length; i++) {
129
- const proj = sortedCloudProjects[i];
130
- if (!proj) continue;
131
- const isLast = i === sortedCloudProjects.length - 1;
132
- const prefix = isLast ? "└──" : "├──";
133
-
134
- const statusBadge = buildStatusBadge(proj);
135
- console.error(` ${colors.dim}${prefix}${colors.reset} ${proj.name} ${statusBadge}`);
136
- }
170
+ // Section 2: Local projects (grouped by parent dir)
171
+ if (groups.local.length > 0) {
137
172
  console.error("");
173
+ console.error(
174
+ ` ${colors.dim}${STATUS_ICONS["local-only"]} Local (${groups.local.length})${colors.reset}`,
175
+ );
176
+ console.error(formatLocalSection(groups.local, { tagColorMap }));
138
177
  }
139
178
 
140
- // Summary
141
- const liveCount = projects.filter((p) => p.status === "live").length;
142
- const localOnlyCount = projects.filter((p) => p.status === "local-only").length;
143
- const errorCount = projects.filter((p) => p.status === "error").length;
179
+ // Section 3: Cloud-only (show last N by updatedAt)
180
+ if (groups.cloudOnly.length > 0) {
181
+ const CLOUD_LIMIT = 5;
182
+ const sorted = sortByUpdated(groups.cloudOnly);
144
183
 
145
- const parts: string[] = [];
146
- if (liveCount > 0) parts.push(`${liveCount} live`);
147
- if (localOnlyCount > 0) parts.push(`${localOnlyCount} local-only`);
148
- if (errorCount > 0) parts.push(`${errorCount} error`);
184
+ console.error("");
185
+ console.error(
186
+ formatCloudSection(sorted, { limit: CLOUD_LIMIT, total: groups.cloudOnly.length, tagColorMap }),
187
+ );
188
+ }
149
189
 
150
- const summary = parts.length > 0 ? ` (${parts.join(", ")})` : "";
151
- info(`${projects.length} projects${summary}`);
190
+ // Footer hint
191
+ console.error("");
192
+ info("jack ls --all for full list, --status error to filter");
152
193
  console.error("");
153
194
  }
154
195
 
155
- // Color codes
156
- const colors = {
157
- reset: "\x1b[0m",
158
- dim: "\x1b[90m",
159
- green: "\x1b[32m",
160
- yellow: "\x1b[33m",
161
- red: "\x1b[31m",
162
- cyan: "\x1b[36m",
163
- };
164
-
165
196
  /**
166
- * Build a user-friendly status badge for a project
197
+ * Render flat table (for --all mode)
167
198
  */
168
- function buildStatusBadge(project: ResolvedProject): string {
169
- switch (project.status) {
170
- case "live":
171
- return `${colors.green}[live]${colors.reset} ${colors.cyan}${project.url || ""}${colors.reset}`;
172
- case "local-only":
173
- return `${colors.dim}[local only]${colors.reset}`;
174
- case "error":
175
- return `${colors.red}[error]${colors.reset} ${project.errorMessage || "deployment failed"}`;
176
- case "syncing":
177
- return `${colors.yellow}[syncing]${colors.reset}`;
178
- default:
179
- return "";
199
+ function renderFlatTable(items: ProjectListItem[]): void {
200
+ // Sort: errors first, then by name
201
+ const sorted = [...items].sort((a, b) => {
202
+ if (a.status === "error" && b.status !== "error") return -1;
203
+ if (a.status !== "error" && b.status === "error") return 1;
204
+ return a.name.localeCompare(b.name);
205
+ });
206
+
207
+ // Build consistent tag color map
208
+ const tagColorMap = buildTagColorMap(items);
209
+
210
+ console.error("");
211
+ info(`${items.length} projects`);
212
+ console.error("");
213
+
214
+ // Header
215
+ console.error(` ${colors.dim}${"NAME".padEnd(22)} ${"STATUS".padEnd(12)} URL${colors.reset}`);
216
+
217
+ // Rows
218
+ for (const item of sorted) {
219
+ const icon = STATUS_ICONS[item.status] || "?";
220
+ const statusColor =
221
+ item.status === "error" || item.status === "auth-expired"
222
+ ? colors.red
223
+ : item.status === "live"
224
+ ? colors.green
225
+ : item.status === "syncing"
226
+ ? colors.yellow
227
+ : colors.dim;
228
+
229
+ const name = item.name.slice(0, 20).padEnd(22);
230
+ const tags = formatTagsInline(item.tags, tagColorMap);
231
+ const status = item.status.padEnd(12);
232
+ const url = item.url ? item.url.replace("https://", "") : "\u2014"; // em-dash
233
+
234
+ console.error(
235
+ ` ${statusColor}${icon}${colors.reset} ${name}${tags ? ` ${tags}` : ""} ${statusColor}${status}${colors.reset} ${url}`,
236
+ );
180
237
  }
238
+
239
+ console.error("");
181
240
  }
182
241
 
183
242
  /**
@@ -368,8 +427,8 @@ async function removeProjectEntry(args: string[]): Promise<void> {
368
427
 
369
428
  // Show where we'll remove from
370
429
  const locations: string[] = [];
371
- if (project.sources.registry) {
372
- locations.push("local registry");
430
+ if (project.sources.filesystem) {
431
+ locations.push("local project");
373
432
  }
374
433
  if (project.sources.controlPlane) {
375
434
  locations.push("jack cloud");
@@ -447,16 +506,15 @@ async function scanProjects(args: string[]): Promise<void> {
447
506
 
448
507
  outputSpinner.start(`Scanning ${targetDir} for jack projects...`);
449
508
 
450
- const { scanDirectoryForProjects, registerDiscoveredProjects } = await import(
451
- "../lib/local-paths.ts"
452
- );
509
+ const { scanAndRegisterProjects } = await import("../lib/paths-index.ts");
453
510
 
454
- const discovered = await scanDirectoryForProjects(absoluteDir);
511
+ // scanAndRegisterProjects both discovers and registers projects
512
+ const discovered = await scanAndRegisterProjects(absoluteDir);
455
513
  outputSpinner.stop();
456
514
 
457
515
  if (discovered.length === 0) {
458
- info("No jack projects found");
459
- info("Projects must have a wrangler.toml or wrangler.jsonc file");
516
+ info("No linked jack projects found");
517
+ info("Projects must have a .jack/project.json file");
460
518
  return;
461
519
  }
462
520
 
@@ -467,17 +525,16 @@ async function scanProjects(args: string[]): Promise<void> {
467
525
  for (let i = 0; i < discovered.length; i++) {
468
526
  const proj = discovered[i];
469
527
  if (!proj) continue;
528
+ // Extract project name from path
529
+ const projectName = proj.path.split("/").pop() || proj.projectId;
470
530
  const displayPath = proj.path.startsWith(home) ? `~${proj.path.slice(home.length)}` : proj.path;
471
531
  const isLast = i === discovered.length - 1;
472
532
  const prefix = isLast ? "└──" : "├──";
473
533
  console.error(
474
- ` ${colors.dim}${prefix}${colors.reset} ${proj.name} ${colors.dim}${displayPath}${colors.reset}`,
534
+ ` ${colors.dim}${prefix}${colors.reset} ${projectName} ${colors.dim}${displayPath}${colors.reset}`,
475
535
  );
476
536
  }
477
537
 
478
- // Register all discovered projects
479
- await registerDiscoveredProjects(discovered);
480
-
481
538
  console.error("");
482
539
  success(`Registered ${discovered.length} local path(s)`);
483
540
  }
@@ -11,7 +11,7 @@ import { $ } from "bun";
11
11
  import { getControlApiUrl } from "../lib/control-plane.ts";
12
12
  import { JackError, JackErrorCode } from "../lib/errors.ts";
13
13
  import { error, info, output, success, warn } from "../lib/output.ts";
14
- import { type Project, getProject } from "../lib/registry.ts";
14
+ import { type LocalProjectLink, readProjectLink } from "../lib/project-link.ts";
15
15
  import { getProjectNameFromDir } from "../lib/storage/index.ts";
16
16
 
17
17
  interface SecretsOptions {
@@ -68,7 +68,7 @@ function showHelp(): void {
68
68
  */
69
69
  async function resolveProjectContext(options: SecretsOptions): Promise<{
70
70
  projectName: string;
71
- project: Project | null;
71
+ link: LocalProjectLink | null;
72
72
  isManaged: boolean;
73
73
  projectId: string | null;
74
74
  }> {
@@ -86,11 +86,12 @@ async function resolveProjectContext(options: SecretsOptions): Promise<{
86
86
  }
87
87
  }
88
88
 
89
- const project = await getProject(projectName);
90
- const isManaged = project?.deploy_mode === "managed";
91
- const projectId = project?.remote?.project_id ?? null;
89
+ // Read deploy mode from .jack/project.json
90
+ const link = await readProjectLink(process.cwd());
91
+ const isManaged = link?.deploy_mode === "managed";
92
+ const projectId = link?.project_id ?? null;
92
93
 
93
- return { projectName, project, isManaged, projectId };
94
+ return { projectName, link, isManaged, projectId };
94
95
  }
95
96
 
96
97
  /**
@@ -3,7 +3,7 @@ import { fetchProjectResources } from "../lib/control-plane.ts";
3
3
  import { formatSize } from "../lib/format.ts";
4
4
  import { promptSelect } from "../lib/hooks.ts";
5
5
  import { error, info, item, output as outputSpinner, success, warn } from "../lib/output.ts";
6
- import { getProject } from "../lib/registry.ts";
6
+ import { readProjectLink } from "../lib/project-link.ts";
7
7
  import { parseWranglerResources } from "../lib/resources.ts";
8
8
  import {
9
9
  deleteDatabase,
@@ -43,12 +43,13 @@ async function ensureLocalProjectContext(projectName: string): Promise<void> {
43
43
  * For BYO: parse from wrangler.jsonc
44
44
  */
45
45
  async function resolveDatabaseInfo(projectName: string): Promise<ResolvedDatabaseInfo | null> {
46
- const project = await getProject(projectName);
46
+ // Read deploy mode from .jack/project.json
47
+ const link = await readProjectLink(process.cwd());
47
48
 
48
49
  // For managed projects, fetch from control plane
49
- if (project?.deploy_mode === "managed" && project.remote?.project_id) {
50
+ if (link?.deploy_mode === "managed") {
50
51
  try {
51
- const resources = await fetchProjectResources(project.remote.project_id);
52
+ const resources = await fetchProjectResources(link.project_id);
52
53
  const d1 = resources.find((r) => r.resource_type === "d1");
53
54
  if (d1) {
54
55
  return {
@@ -0,0 +1,282 @@
1
+ /**
2
+ * jack tag - Manage project tags
3
+ *
4
+ * Usage:
5
+ * jack tag add <tags...> Add tags to current project
6
+ * jack tag add <project> <tags...> Add tags to named project
7
+ * jack tag remove <tags...> Remove tags from current project
8
+ * jack tag remove <project> <tags...> Remove tags from named project
9
+ * jack tag list List all tags across projects
10
+ * jack tag list [project] List tags for a specific project
11
+ */
12
+
13
+ import { error, info, item, success } from "../lib/output.ts";
14
+ import { readProjectLink } from "../lib/project-link.ts";
15
+ import {
16
+ addTags,
17
+ findProjectPathByName,
18
+ getAllTagsWithCounts,
19
+ getProjectTags,
20
+ removeTags,
21
+ validateTags,
22
+ } from "../lib/tags.ts";
23
+
24
+ export default async function tag(subcommand?: string, args: string[] = []): Promise<void> {
25
+ if (!subcommand) {
26
+ showHelp();
27
+ return;
28
+ }
29
+
30
+ switch (subcommand) {
31
+ case "add":
32
+ return await addTagsCommand(args);
33
+ case "remove":
34
+ return await removeTagsCommand(args);
35
+ case "list":
36
+ return await listTagsCommand(args);
37
+ case "--help":
38
+ case "-h":
39
+ case "help":
40
+ showHelp();
41
+ return;
42
+ default:
43
+ error(`Unknown subcommand: ${subcommand}`);
44
+ info("Available: add, remove, list");
45
+ process.exit(1);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Show help for the tag command
51
+ */
52
+ function showHelp(): void {
53
+ console.error(`
54
+ jack tag - Manage project tags
55
+
56
+ Usage
57
+ $ jack tag add <tags...> Add tags to current project
58
+ $ jack tag add <project> <tags...> Add tags to named project
59
+ $ jack tag remove <tags...> Remove tags from current project
60
+ $ jack tag remove <project> <tags...> Remove tags from named project
61
+ $ jack tag list List all tags across projects
62
+ $ jack tag list [project] List tags for a specific project
63
+
64
+ Tag Format
65
+ Tags must be lowercase alphanumeric with optional colons and hyphens.
66
+ Examples: backend, api:v2, my-service, prod
67
+
68
+ Examples
69
+ $ jack tag add backend api Add tags in project directory
70
+ $ jack tag add my-app backend Add tag to my-app project
71
+ $ jack tag remove deprecated Remove tag from current project
72
+ $ jack tag list Show all tags with counts
73
+ $ jack tag list my-app Show tags for my-app
74
+ `);
75
+ }
76
+
77
+ /**
78
+ * Resolve project path from arguments
79
+ * Returns [projectPath, remainingArgs]
80
+ *
81
+ * Logic:
82
+ * 1. If in a linked project directory, use cwd and all args are tags
83
+ * 2. If not in project directory, first arg might be project name
84
+ */
85
+ async function resolveProjectAndTags(args: string[]): Promise<[string | null, string[]]> {
86
+ const cwd = process.cwd();
87
+
88
+ // Check if we're in a linked project directory
89
+ const link = await readProjectLink(cwd);
90
+
91
+ if (link) {
92
+ // In a project directory - all args are tags
93
+ return [cwd, args];
94
+ }
95
+
96
+ // Not in a project directory - first arg might be project name
97
+ if (args.length === 0) {
98
+ return [null, []];
99
+ }
100
+
101
+ const firstArg = args[0] as string; // Safe: we checked args.length > 0 above
102
+ const rest = args.slice(1);
103
+
104
+ // Try to find project by name
105
+ const projectPath = await findProjectPathByName(firstArg);
106
+
107
+ if (projectPath) {
108
+ // First arg was a project name
109
+ return [projectPath, rest];
110
+ }
111
+
112
+ // First arg wasn't a project name - we're not in a project directory
113
+ // and couldn't find a matching project
114
+ return [null, args];
115
+ }
116
+
117
+ /**
118
+ * Add tags to a project
119
+ */
120
+ async function addTagsCommand(args: string[]): Promise<void> {
121
+ const [projectPath, tagArgs] = await resolveProjectAndTags(args);
122
+
123
+ if (!projectPath) {
124
+ error("Not in a project directory and no valid project name provided");
125
+ info("Run from a project directory or specify project name: jack tag add <project> <tags...>");
126
+ process.exit(1);
127
+ }
128
+
129
+ if (tagArgs.length === 0) {
130
+ error("No tags specified");
131
+ info("Usage: jack tag add <tags...>");
132
+ process.exit(1);
133
+ }
134
+
135
+ // Validate tags first
136
+ const validation = validateTags(tagArgs);
137
+ if (!validation.valid) {
138
+ error("Invalid tags:");
139
+ for (const { tag, reason } of validation.invalidTags) {
140
+ item(`"${tag}": ${reason}`);
141
+ }
142
+ process.exit(1);
143
+ }
144
+
145
+ const result = await addTags(projectPath, tagArgs);
146
+
147
+ if (!result.success) {
148
+ error(result.error || "Failed to add tags");
149
+ process.exit(1);
150
+ }
151
+
152
+ if (result.added && result.added.length > 0) {
153
+ success(`Added tags: ${result.added.join(", ")}`);
154
+ }
155
+
156
+ if (result.skipped && result.skipped.length > 0) {
157
+ info(`Already present: ${result.skipped.join(", ")}`);
158
+ }
159
+
160
+ if (result.tags.length > 0) {
161
+ info(`Current tags: ${result.tags.join(", ")}`);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Remove tags from a project
167
+ */
168
+ async function removeTagsCommand(args: string[]): Promise<void> {
169
+ const [projectPath, tagArgs] = await resolveProjectAndTags(args);
170
+
171
+ if (!projectPath) {
172
+ error("Not in a project directory and no valid project name provided");
173
+ info(
174
+ "Run from a project directory or specify project name: jack tag remove <project> <tags...>",
175
+ );
176
+ process.exit(1);
177
+ }
178
+
179
+ if (tagArgs.length === 0) {
180
+ error("No tags specified");
181
+ info("Usage: jack tag remove <tags...>");
182
+ process.exit(1);
183
+ }
184
+
185
+ const result = await removeTags(projectPath, tagArgs);
186
+
187
+ if (!result.success) {
188
+ error(result.error || "Failed to remove tags");
189
+ process.exit(1);
190
+ }
191
+
192
+ if (result.removed && result.removed.length > 0) {
193
+ success(`Removed tags: ${result.removed.join(", ")}`);
194
+ }
195
+
196
+ if (result.skipped && result.skipped.length > 0) {
197
+ info(`Not found: ${result.skipped.join(", ")}`);
198
+ }
199
+
200
+ if (result.tags.length > 0) {
201
+ info(`Remaining tags: ${result.tags.join(", ")}`);
202
+ } else {
203
+ info("No tags remaining");
204
+ }
205
+ }
206
+
207
+ /**
208
+ * List tags for a project or all tags across projects
209
+ */
210
+ async function listTagsCommand(args: string[]): Promise<void> {
211
+ const [projectArg] = args;
212
+
213
+ if (projectArg) {
214
+ // List tags for a specific project
215
+ await listProjectTags(projectArg);
216
+ } else {
217
+ // Check if we're in a project directory
218
+ const cwd = process.cwd();
219
+ const link = await readProjectLink(cwd);
220
+
221
+ if (link) {
222
+ // In a project directory - show tags for this project
223
+ await listProjectTagsForPath(cwd);
224
+ } else {
225
+ // Not in project directory - show all tags
226
+ await listAllTags();
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * List tags for a specific project by name
233
+ */
234
+ async function listProjectTags(projectName: string): Promise<void> {
235
+ const projectPath = await findProjectPathByName(projectName);
236
+
237
+ if (!projectPath) {
238
+ error(`Project not found: ${projectName}`);
239
+ process.exit(1);
240
+ }
241
+
242
+ await listProjectTagsForPath(projectPath);
243
+ }
244
+
245
+ /**
246
+ * List tags for a project at a specific path
247
+ */
248
+ async function listProjectTagsForPath(projectPath: string): Promise<void> {
249
+ const tags = await getProjectTags(projectPath);
250
+
251
+ console.error("");
252
+ if (tags.length === 0) {
253
+ info("No tags for this project");
254
+ info("Add tags with: jack tag add <tags...>");
255
+ } else {
256
+ info(`Tags (${tags.length}):`);
257
+ for (const tag of tags) {
258
+ item(tag);
259
+ }
260
+ }
261
+ console.error("");
262
+ }
263
+
264
+ /**
265
+ * List all tags across all projects with counts
266
+ */
267
+ async function listAllTags(): Promise<void> {
268
+ const tagCounts = await getAllTagsWithCounts();
269
+
270
+ console.error("");
271
+ if (tagCounts.length === 0) {
272
+ info("No tags found across any projects");
273
+ info("Add tags with: jack tag add <tags...>");
274
+ } else {
275
+ info(`All tags (${tagCounts.length}):`);
276
+ for (const { tag, count } of tagCounts) {
277
+ const projectLabel = count === 1 ? "project" : "projects";
278
+ item(`${tag} (${count} ${projectLabel})`);
279
+ }
280
+ }
281
+ console.error("");
282
+ }