prjct-cli 0.55.4 → 0.56.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/CHANGELOG.md CHANGED
@@ -1,11 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.56.0] - 2026-02-05
4
+
5
+ ### Features
6
+
7
+ - implement output tiers for cleaner CLI output (#88)
8
+
9
+ ### Bug Fixes
10
+
11
+ - make lefthook hooks portable (no bun required) (#92)
12
+ - skip lefthook in CI environments (#91)
13
+ - use npm instead of bun in lefthook hooks (#90)
14
+ - add pre-commit hooks to prevent commits with lint errors (#89)
15
+
16
+
17
+ ## [0.55.6] - 2026-02-04
18
+
19
+ ### Fixed
20
+
21
+ - **Portable git hooks**: Removed `skip_in_ci` and test from pre-push hook - hooks now work everywhere with npm-only commands (lint, typecheck). Tests run in CI where bun is properly installed.
22
+
23
+ ## [0.55.5] - 2026-02-05
24
+
25
+ ### Added
26
+
27
+ - **Pre-commit hooks**: Added lefthook for git hooks - blocks commits with lint/format errors
28
+ - **Pre-push hooks**: Runs typecheck and tests before push
29
+
30
+ ### Fixed
31
+
32
+ - Fixed 3 lint errors (import ordering, formatting)
33
+
3
34
  ## [0.55.4] - 2026-02-05
4
35
 
36
+ ### Features
37
+
38
+ - **Output tiers**: New tiered output system (silent/minimal/compact/verbose) for cleaner CLI output
39
+ - **Human-friendly Linear output**: `p. linear list` now shows concise table instead of raw JSON
40
+ - **--json flag**: Use `--json` to get machine-parseable JSON output when needed
41
+ - **--verbose flag**: Use `--verbose` for full untruncated output
42
+
43
+ ### Improved
44
+
45
+ - Removed noisy `[linear] Connected as...` messages from every API call
46
+ - Added `limitLines()` and `formatForHuman()` utilities for consistent output formatting
47
+
5
48
  ### Bug Fixes
6
49
 
7
50
  - remove legacy p.*.md commands on sync
8
51
 
52
+ ### Tests
53
+
54
+ - Added 11 new tests for output tier functionality
9
55
 
10
56
  ## [0.55.3] - 2026-02-05
11
57
 
@@ -4,7 +4,14 @@
4
4
  */
5
5
 
6
6
  import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
7
- import out from '../../utils/output'
7
+ import out, {
8
+ formatForHuman,
9
+ getOutputTier,
10
+ getTierConfig,
11
+ limitLines,
12
+ OUTPUT_TIERS,
13
+ setOutputTier,
14
+ } from '../../utils/output'
8
15
 
9
16
  describe('Output Module', () => {
10
17
  let consoleLogSpy: ReturnType<typeof spyOn>
@@ -151,4 +158,115 @@ describe('Output Module', () => {
151
158
  expect(consoleLogSpy).toHaveBeenCalled()
152
159
  })
153
160
  })
161
+
162
+ describe('Output Tiers', () => {
163
+ afterEach(() => {
164
+ setOutputTier('compact') // Reset to default
165
+ })
166
+
167
+ it('should have correct tier configs', () => {
168
+ expect(OUTPUT_TIERS.silent.maxLines).toBe(0)
169
+ expect(OUTPUT_TIERS.minimal.maxLines).toBe(1)
170
+ expect(OUTPUT_TIERS.compact.maxLines).toBe(4)
171
+ expect(OUTPUT_TIERS.verbose.maxLines).toBe(Infinity)
172
+ })
173
+
174
+ it('should get and set output tier', () => {
175
+ setOutputTier('verbose')
176
+ expect(getOutputTier()).toBe('verbose')
177
+
178
+ setOutputTier('minimal')
179
+ expect(getOutputTier()).toBe('minimal')
180
+ })
181
+
182
+ it('should return correct tier config', () => {
183
+ setOutputTier('compact')
184
+ const config = getTierConfig()
185
+ expect(config.maxLines).toBe(4)
186
+ expect(config.maxCharsPerLine).toBe(80)
187
+ expect(config.showMetrics).toBe(true)
188
+ })
189
+ })
190
+
191
+ describe('limitLines()', () => {
192
+ it('should limit content to maxLines', () => {
193
+ setOutputTier('compact') // maxLines = 4
194
+ const content = 'line1\nline2\nline3\nline4\nline5\nline6'
195
+ const result = limitLines(content)
196
+
197
+ expect(result.split('\n').length).toBe(5) // 4 lines + "...2 more lines"
198
+ expect(result).toContain('...2 more lines')
199
+ })
200
+
201
+ it('should not truncate if under limit', () => {
202
+ setOutputTier('compact')
203
+ const content = 'line1\nline2'
204
+ const result = limitLines(content)
205
+
206
+ expect(result).toBe(content)
207
+ })
208
+
209
+ it('should respect custom maxLines parameter', () => {
210
+ const content = 'line1\nline2\nline3\nline4'
211
+ const result = limitLines(content, 2)
212
+
213
+ expect(result.split('\n').length).toBe(3) // 2 lines + indicator
214
+ expect(result).toContain('...2 more lines')
215
+ })
216
+
217
+ it('should return content unchanged for verbose tier', () => {
218
+ setOutputTier('verbose')
219
+ const content = 'line1\nline2\nline3\nline4\nline5'
220
+ const result = limitLines(content)
221
+
222
+ expect(result).toBe(content)
223
+ })
224
+ })
225
+
226
+ describe('formatForHuman()', () => {
227
+ afterEach(() => {
228
+ setOutputTier('compact')
229
+ })
230
+
231
+ it('should format Linear issue object', () => {
232
+ const issue = {
233
+ identifier: 'PRJ-123',
234
+ title: 'Test issue title',
235
+ status: 'in_progress',
236
+ priority: 'high',
237
+ url: 'https://linear.app/test',
238
+ }
239
+
240
+ const result = formatForHuman(issue)
241
+ expect(result).toContain('PRJ-123')
242
+ expect(result).toContain('Test issue title')
243
+ expect(result).toContain('in_progress')
244
+ })
245
+
246
+ it('should format issue list', () => {
247
+ const list = {
248
+ issues: [
249
+ { identifier: 'PRJ-1', title: 'First issue', priority: 'high' },
250
+ { identifier: 'PRJ-2', title: 'Second issue', priority: 'none' },
251
+ ],
252
+ }
253
+
254
+ const result = formatForHuman(list)
255
+ expect(result).toContain('PRJ-1')
256
+ expect(result).toContain('PRJ-2')
257
+ })
258
+
259
+ it('should return empty string for silent tier', () => {
260
+ setOutputTier('silent')
261
+ const result = formatForHuman({ test: 'data' })
262
+ expect(result).toBe('')
263
+ })
264
+
265
+ it('should return full JSON for verbose tier', () => {
266
+ setOutputTier('verbose')
267
+ const data = { test: 'data', nested: { key: 'value' } }
268
+ const result = formatForHuman(data)
269
+ expect(result).toBe(JSON.stringify(data, null, 2))
270
+ })
271
+ })
154
272
  })
@@ -4,6 +4,11 @@
4
4
  *
5
5
  * Usage: bun core/cli/linear.ts --project <projectId> <command> [args...]
6
6
  *
7
+ * Flags:
8
+ * --project <id> - Project ID (required)
9
+ * --json - Output raw JSON (default: human-readable)
10
+ * --verbose - Show all details (no truncation)
11
+ *
7
12
  * Commands:
8
13
  * setup <apiKey> [teamId] - Store API key in project credentials
9
14
  * list - List my assigned issues
@@ -21,11 +26,12 @@
21
26
  * projects - List available projects
22
27
  * status - Check connection status
23
28
  *
24
- * All output is JSON for easy parsing by Claude.
29
+ * Default output is human-readable. Use --json for machine parsing.
25
30
  */
26
31
 
27
32
  import type { CreateIssueInput, Issue } from '../integrations/issue-tracker/types'
28
33
  import { linearService, linearSync } from '../integrations/linear'
34
+ import { formatForHuman, setOutputTier } from '../utils/output'
29
35
  import {
30
36
  getCredentialSource,
31
37
  getLinearApiKey,
@@ -44,20 +50,41 @@ if (projectIdx !== -1 && args[projectIdx + 1]) {
44
50
  args.splice(projectIdx, 2)
45
51
  }
46
52
 
53
+ // Extract --json flag (raw JSON output)
54
+ const jsonIdx = args.indexOf('--json')
55
+ const jsonMode = jsonIdx !== -1
56
+ if (jsonMode) args.splice(jsonIdx, 1)
57
+
58
+ // Extract --verbose flag
59
+ const verboseIdx = args.indexOf('--verbose')
60
+ const verboseMode = verboseIdx !== -1
61
+ if (verboseMode) {
62
+ args.splice(verboseIdx, 1)
63
+ setOutputTier('verbose')
64
+ }
65
+
47
66
  const [command, ...commandArgs] = args
48
67
 
49
68
  /**
50
- * Output result as JSON
69
+ * Output result - human-readable by default, JSON with --json flag
51
70
  */
52
71
  function output(data: unknown): void {
53
- console.log(JSON.stringify(data, null, 2))
72
+ if (jsonMode) {
73
+ console.log(JSON.stringify(data, null, 2))
74
+ } else {
75
+ console.log(formatForHuman(data))
76
+ }
54
77
  }
55
78
 
56
79
  /**
57
- * Output error as JSON and exit
80
+ * Output error and exit
58
81
  */
59
82
  function error(message: string, code = 1): never {
60
- console.error(JSON.stringify({ error: message }))
83
+ if (jsonMode) {
84
+ console.error(JSON.stringify({ error: message }))
85
+ } else {
86
+ console.error(`Error: ${message}`)
87
+ }
61
88
  process.exit(code)
62
89
  }
63
90
 
@@ -148,17 +175,28 @@ async function main(): Promise<void> {
148
175
  issues = await linearService.fetchAssignedIssues({ limit })
149
176
  }
150
177
 
151
- output({
152
- count: issues.length,
153
- issues: issues.map((issue) => ({
154
- id: issue.id,
155
- identifier: issue.externalId,
156
- title: issue.title,
157
- status: issue.status,
158
- priority: issue.priority,
159
- url: issue.url,
160
- })),
161
- })
178
+ const issueList = issues.map((issue) => ({
179
+ id: issue.id,
180
+ identifier: issue.externalId,
181
+ title: issue.title,
182
+ status: issue.status,
183
+ priority: issue.priority,
184
+ url: issue.url,
185
+ }))
186
+
187
+ if (jsonMode) {
188
+ output({ count: issues.length, issues: issueList })
189
+ } else {
190
+ // Human-friendly table output
191
+ console.log(`Your issues (${issues.length}):`)
192
+ for (const issue of issueList.slice(0, 10)) {
193
+ const p = issue.priority && issue.priority !== 'none' ? ` [${issue.priority}]` : ''
194
+ console.log(` ${issue.identifier} ${issue.title.slice(0, 50)}${p}`)
195
+ }
196
+ if (issues.length > 10) {
197
+ console.log(` ...${issues.length - 10} more`)
198
+ }
199
+ }
162
200
  break
163
201
  }
164
202
 
@@ -199,7 +237,18 @@ async function main(): Promise<void> {
199
237
  error(`Issue not found: ${id}`)
200
238
  }
201
239
 
202
- output(issue)
240
+ if (jsonMode) {
241
+ output(issue)
242
+ } else {
243
+ // Human-friendly issue display
244
+ console.log(`${issue.externalId}: ${issue.title}`)
245
+ console.log(`Status: ${issue.status} | Priority: ${issue.priority || 'none'}`)
246
+ if (issue.description) {
247
+ const desc = issue.description.slice(0, 200)
248
+ console.log(`\n${desc}${issue.description.length > 200 ? '...' : ''}`)
249
+ }
250
+ console.log(`\n${issue.url}`)
251
+ }
203
252
  break
204
253
  }
205
254
 
@@ -371,11 +420,12 @@ async function main(): Promise<void> {
371
420
  const creds = await getProjectCredentials(projectId)
372
421
 
373
422
  if (!apiKey) {
374
- output({
375
- configured: false,
376
- source: 'none',
377
- message: 'Linear not configured. Run: p. linear setup',
378
- })
423
+ if (jsonMode) {
424
+ output({ configured: false, source: 'none', message: 'Linear not configured' })
425
+ } else {
426
+ console.log('Linear: Not configured')
427
+ console.log('Run: p. linear setup')
428
+ }
379
429
  break
380
430
  }
381
431
 
@@ -384,19 +434,28 @@ async function main(): Promise<void> {
384
434
  await linearService.initializeFromApiKey(apiKey, creds.linear?.teamId)
385
435
  const teams = await linearService.getTeams()
386
436
 
387
- output({
388
- configured: true,
389
- source,
390
- teamId: creds.linear?.teamId,
391
- teamKey: creds.linear?.teamKey,
392
- teamsAvailable: teams.length,
393
- })
437
+ if (jsonMode) {
438
+ output({
439
+ configured: true,
440
+ source,
441
+ teamId: creds.linear?.teamId,
442
+ teamKey: creds.linear?.teamKey,
443
+ teamsAvailable: teams.length,
444
+ })
445
+ } else {
446
+ console.log(`Linear: Connected`)
447
+ if (creds.linear?.teamKey) {
448
+ console.log(`Team: ${creds.linear.teamKey}`)
449
+ }
450
+ console.log(`Teams: ${teams.length} available`)
451
+ }
394
452
  } catch (err) {
395
- output({
396
- configured: true,
397
- source,
398
- connectionError: (err as Error).message,
399
- })
453
+ if (jsonMode) {
454
+ output({ configured: true, source, connectionError: (err as Error).message })
455
+ } else {
456
+ console.log(`Linear: Connection error`)
457
+ console.log(`Error: ${(err as Error).message}`)
458
+ }
400
459
  }
401
460
  break
402
461
  }
@@ -79,11 +79,9 @@ export class LinearProvider implements IssueTrackerProvider {
79
79
 
80
80
  this.sdk = new LinearSDK({ apiKey })
81
81
 
82
- // Verify connection
82
+ // Verify connection silently (no output noise)
83
83
  try {
84
- const viewer = await this.sdk.viewer
85
- // Use stderr for logs to not break JSON output
86
- console.error(`[linear] Connected as ${viewer.name} (${viewer.email})`)
84
+ await this.sdk.viewer
87
85
  } catch (error) {
88
86
  this.sdk = null
89
87
  throw new Error(`Linear connection failed: ${(error as Error).message}`)
@@ -4,8 +4,9 @@
4
4
  * With prjct branding
5
5
  *
6
6
  * Supports --quiet mode for CI/CD and scripting
7
+ * Supports output tiers: silent, minimal, compact, verbose
7
8
  *
8
- * @see PRJ-130
9
+ * @see PRJ-105, PRJ-130
9
10
  */
10
11
 
11
12
  import chalk from 'chalk'
@@ -16,6 +17,49 @@ import { getError } from './error-messages'
16
17
  const _FRAMES = branding.spinner.frames
17
18
  const SPEED = branding.spinner.speed
18
19
 
20
+ /**
21
+ * Output tier configuration
22
+ * Controls verbosity of CLI output
23
+ */
24
+ export type OutputTier = 'silent' | 'minimal' | 'compact' | 'verbose'
25
+
26
+ export interface TierConfig {
27
+ maxLines: number
28
+ maxCharsPerLine: number
29
+ showMetrics: boolean
30
+ }
31
+
32
+ export const OUTPUT_TIERS: Record<OutputTier, TierConfig> = {
33
+ silent: { maxLines: 0, maxCharsPerLine: 0, showMetrics: false },
34
+ minimal: { maxLines: 1, maxCharsPerLine: 65, showMetrics: false },
35
+ compact: { maxLines: 4, maxCharsPerLine: 80, showMetrics: true },
36
+ verbose: { maxLines: Infinity, maxCharsPerLine: Infinity, showMetrics: true },
37
+ }
38
+
39
+ // Current output tier (default: compact for human-readable output)
40
+ let currentTier: OutputTier = 'compact'
41
+
42
+ /**
43
+ * Set the output tier
44
+ */
45
+ export function setOutputTier(tier: OutputTier): void {
46
+ currentTier = tier
47
+ }
48
+
49
+ /**
50
+ * Get current output tier
51
+ */
52
+ export function getOutputTier(): OutputTier {
53
+ return currentTier
54
+ }
55
+
56
+ /**
57
+ * Get current tier config
58
+ */
59
+ export function getTierConfig(): TierConfig {
60
+ return OUTPUT_TIERS[currentTier]
61
+ }
62
+
19
63
  /**
20
64
  * Centralized icons for consistent output
21
65
  */
@@ -52,8 +96,89 @@ export function isQuietMode(): boolean {
52
96
  return quietMode
53
97
  }
54
98
 
55
- const truncate = (s: string | undefined | null, max = 50): string =>
56
- s && s.length > max ? `${s.slice(0, max - 1)}…` : s || ''
99
+ /**
100
+ * Truncate string to max chars (uses tier config if no max specified)
101
+ */
102
+ const truncate = (s: string | undefined | null, max?: number): string => {
103
+ const limit = max ?? (getTierConfig().maxCharsPerLine || 50)
104
+ return s && s.length > limit ? `${s.slice(0, limit - 1)}…` : s || ''
105
+ }
106
+
107
+ /**
108
+ * Limit output to maxLines (respects tier config)
109
+ * Returns truncated content with "...N more lines" indicator
110
+ */
111
+ /**
112
+ * Limit output to maxLines (respects tier config)
113
+ * Returns truncated content with "...N more lines" indicator
114
+ */
115
+ export function limitLines(content: string, maxLines?: number): string {
116
+ const limit = maxLines ?? getTierConfig().maxLines
117
+ if (limit === Infinity || limit === 0) return content
118
+
119
+ const lines = content.split('\n')
120
+ if (lines.length <= limit) return content
121
+
122
+ const shown = lines.slice(0, limit)
123
+ const remaining = lines.length - limit
124
+ return `${shown.join('\n')}\n${chalk.dim(`...${remaining} more lines`)}`
125
+ }
126
+
127
+ /**
128
+ * Format data for human-readable output (respects tier)
129
+ * Use this instead of JSON.stringify for CLI output
130
+ */
131
+ export function formatForHuman(data: unknown): string {
132
+ const tier = getTierConfig()
133
+
134
+ if (currentTier === 'silent') return ''
135
+ if (currentTier === 'verbose') return JSON.stringify(data, null, 2)
136
+
137
+ // For minimal/compact: extract key info
138
+ if (typeof data !== 'object' || data === null) {
139
+ return truncate(String(data), tier.maxCharsPerLine)
140
+ }
141
+
142
+ const obj = data as Record<string, unknown>
143
+
144
+ // Linear issue format
145
+ if ('identifier' in obj && 'title' in obj) {
146
+ const lines: string[] = []
147
+ lines.push(`${obj.identifier}: ${truncate(String(obj.title), tier.maxCharsPerLine - 10)}`)
148
+ if (obj.status) lines.push(`Status: ${obj.status}`)
149
+ if (obj.priority && obj.priority !== 'none') lines.push(`Priority: ${obj.priority}`)
150
+ if (obj.url && currentTier === 'compact') lines.push(chalk.dim(String(obj.url)))
151
+ return limitLines(lines.join('\n'), tier.maxLines)
152
+ }
153
+
154
+ // Issue list format
155
+ if ('issues' in obj && Array.isArray(obj.issues)) {
156
+ const issues = obj.issues as Array<Record<string, unknown>>
157
+ const lines = issues.slice(0, tier.maxLines).map((i) => {
158
+ const priority = i.priority && i.priority !== 'none' ? ` [${i.priority}]` : ''
159
+ return `${i.identifier} ${truncate(String(i.title), 50)}${priority}`
160
+ })
161
+ if (issues.length > tier.maxLines) {
162
+ lines.push(chalk.dim(`...${issues.length - tier.maxLines} more`))
163
+ }
164
+ return lines.join('\n')
165
+ }
166
+
167
+ // Generic object: show key fields only
168
+ const keyFields = ['id', 'name', 'title', 'status', 'message', 'success', 'error']
169
+ const relevant = keyFields.filter((k) => k in obj)
170
+ if (relevant.length > 0) {
171
+ return limitLines(
172
+ relevant
173
+ .map((k) => `${k}: ${truncate(String(obj[k]), tier.maxCharsPerLine - k.length - 2)}`)
174
+ .join('\n'),
175
+ tier.maxLines
176
+ )
177
+ }
178
+
179
+ // Fallback: compact JSON
180
+ return limitLines(JSON.stringify(data, null, 2), tier.maxLines)
181
+ }
57
182
 
58
183
  const clear = (): boolean => process.stdout.write(`\r${' '.repeat(80)}\r`)
59
184
 
@@ -2088,20 +2088,82 @@ var output_exports = {};
2088
2088
  __export(output_exports, {
2089
2089
  ERRORS: () => ERRORS,
2090
2090
  ICONS: () => ICONS,
2091
+ OUTPUT_TIERS: () => OUTPUT_TIERS,
2091
2092
  createError: () => createError,
2092
2093
  default: () => output_default,
2094
+ formatForHuman: () => formatForHuman,
2093
2095
  getError: () => getError,
2096
+ getOutputTier: () => getOutputTier,
2097
+ getTierConfig: () => getTierConfig,
2094
2098
  isQuietMode: () => isQuietMode,
2099
+ limitLines: () => limitLines,
2100
+ setOutputTier: () => setOutputTier,
2095
2101
  setQuietMode: () => setQuietMode
2096
2102
  });
2097
2103
  import chalk2 from "chalk";
2104
+ function setOutputTier(tier) {
2105
+ currentTier = tier;
2106
+ }
2107
+ function getOutputTier() {
2108
+ return currentTier;
2109
+ }
2110
+ function getTierConfig() {
2111
+ return OUTPUT_TIERS[currentTier];
2112
+ }
2098
2113
  function setQuietMode(enabled) {
2099
2114
  quietMode = enabled;
2100
2115
  }
2101
2116
  function isQuietMode() {
2102
2117
  return quietMode;
2103
2118
  }
2104
- var _FRAMES, SPEED, ICONS, interval, frame, quietMode, truncate, clear, out, output_default;
2119
+ function limitLines(content, maxLines) {
2120
+ const limit = maxLines ?? getTierConfig().maxLines;
2121
+ if (limit === Infinity || limit === 0) return content;
2122
+ const lines = content.split("\n");
2123
+ if (lines.length <= limit) return content;
2124
+ const shown = lines.slice(0, limit);
2125
+ const remaining = lines.length - limit;
2126
+ return `${shown.join("\n")}
2127
+ ${chalk2.dim(`...${remaining} more lines`)}`;
2128
+ }
2129
+ function formatForHuman(data) {
2130
+ const tier = getTierConfig();
2131
+ if (currentTier === "silent") return "";
2132
+ if (currentTier === "verbose") return JSON.stringify(data, null, 2);
2133
+ if (typeof data !== "object" || data === null) {
2134
+ return truncate(String(data), tier.maxCharsPerLine);
2135
+ }
2136
+ const obj = data;
2137
+ if ("identifier" in obj && "title" in obj) {
2138
+ const lines = [];
2139
+ lines.push(`${obj.identifier}: ${truncate(String(obj.title), tier.maxCharsPerLine - 10)}`);
2140
+ if (obj.status) lines.push(`Status: ${obj.status}`);
2141
+ if (obj.priority && obj.priority !== "none") lines.push(`Priority: ${obj.priority}`);
2142
+ if (obj.url && currentTier === "compact") lines.push(chalk2.dim(String(obj.url)));
2143
+ return limitLines(lines.join("\n"), tier.maxLines);
2144
+ }
2145
+ if ("issues" in obj && Array.isArray(obj.issues)) {
2146
+ const issues = obj.issues;
2147
+ const lines = issues.slice(0, tier.maxLines).map((i) => {
2148
+ const priority = i.priority && i.priority !== "none" ? ` [${i.priority}]` : "";
2149
+ return `${i.identifier} ${truncate(String(i.title), 50)}${priority}`;
2150
+ });
2151
+ if (issues.length > tier.maxLines) {
2152
+ lines.push(chalk2.dim(`...${issues.length - tier.maxLines} more`));
2153
+ }
2154
+ return lines.join("\n");
2155
+ }
2156
+ const keyFields = ["id", "name", "title", "status", "message", "success", "error"];
2157
+ const relevant = keyFields.filter((k) => k in obj);
2158
+ if (relevant.length > 0) {
2159
+ return limitLines(
2160
+ relevant.map((k) => `${k}: ${truncate(String(obj[k]), tier.maxCharsPerLine - k.length - 2)}`).join("\n"),
2161
+ tier.maxLines
2162
+ );
2163
+ }
2164
+ return limitLines(JSON.stringify(data, null, 2), tier.maxLines);
2165
+ }
2166
+ var _FRAMES, SPEED, OUTPUT_TIERS, currentTier, ICONS, interval, frame, quietMode, truncate, clear, out, output_default;
2105
2167
  var init_output = __esm({
2106
2168
  "core/utils/output.ts"() {
2107
2169
  "use strict";
@@ -2110,6 +2172,16 @@ var init_output = __esm({
2110
2172
  init_error_messages();
2111
2173
  _FRAMES = branding_default.spinner.frames;
2112
2174
  SPEED = branding_default.spinner.speed;
2175
+ OUTPUT_TIERS = {
2176
+ silent: { maxLines: 0, maxCharsPerLine: 0, showMetrics: false },
2177
+ minimal: { maxLines: 1, maxCharsPerLine: 65, showMetrics: false },
2178
+ compact: { maxLines: 4, maxCharsPerLine: 80, showMetrics: true },
2179
+ verbose: { maxLines: Infinity, maxCharsPerLine: Infinity, showMetrics: true }
2180
+ };
2181
+ currentTier = "compact";
2182
+ __name(setOutputTier, "setOutputTier");
2183
+ __name(getOutputTier, "getOutputTier");
2184
+ __name(getTierConfig, "getTierConfig");
2113
2185
  ICONS = {
2114
2186
  success: chalk2.green("\u2713"),
2115
2187
  fail: chalk2.red("\u2717"),
@@ -2127,7 +2199,12 @@ var init_output = __esm({
2127
2199
  quietMode = false;
2128
2200
  __name(setQuietMode, "setQuietMode");
2129
2201
  __name(isQuietMode, "isQuietMode");
2130
- truncate = /* @__PURE__ */ __name((s, max = 50) => s && s.length > max ? `${s.slice(0, max - 1)}\u2026` : s || "", "truncate");
2202
+ truncate = /* @__PURE__ */ __name((s, max) => {
2203
+ const limit = max ?? (getTierConfig().maxCharsPerLine || 50);
2204
+ return s && s.length > limit ? `${s.slice(0, limit - 1)}\u2026` : s || "";
2205
+ }, "truncate");
2206
+ __name(limitLines, "limitLines");
2207
+ __name(formatForHuman, "formatForHuman");
2131
2208
  clear = /* @__PURE__ */ __name(() => process.stdout.write(`\r${" ".repeat(80)}\r`), "clear");
2132
2209
  out = {
2133
2210
  // Branding: Show header at start
@@ -23931,8 +24008,7 @@ var init_client = __esm({
23931
24008
  }
23932
24009
  this.sdk = new LinearSDK({ apiKey });
23933
24010
  try {
23934
- const viewer = await this.sdk.viewer;
23935
- console.error(`[linear] Connected as ${viewer.name} (${viewer.email})`);
24011
+ await this.sdk.viewer;
23936
24012
  } catch (error) {
23937
24013
  this.sdk = null;
23938
24014
  throw new Error(`Linear connection failed: ${error.message}`);
@@ -25221,7 +25297,7 @@ var require_package = __commonJS({
25221
25297
  "package.json"(exports, module) {
25222
25298
  module.exports = {
25223
25299
  name: "prjct-cli",
25224
- version: "0.55.4",
25300
+ version: "0.56.0",
25225
25301
  description: "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
25226
25302
  main: "core/index.ts",
25227
25303
  bin: {
@@ -25239,6 +25315,7 @@ var require_package = __commonJS({
25239
25315
  "release:minor": "node scripts/release.js minor",
25240
25316
  "release:major": "node scripts/release.js major",
25241
25317
  postinstall: "node scripts/postinstall.js",
25318
+ prepare: "lefthook install",
25242
25319
  "update-commands": `bun -e "const installer = require('./core/infrastructure/command-installer'); installer.syncCommands().then(r => console.log('Commands updated:', r)).catch(e => console.error('Error:', e.message))"`,
25243
25320
  "install-global": "./scripts/install.sh",
25244
25321
  update: "./scripts/update.sh",
@@ -25288,6 +25365,7 @@ var require_package = __commonJS({
25288
25365
  "@types/bun": "latest",
25289
25366
  "@types/chokidar": "^2.1.7",
25290
25367
  "@types/prompts": "^2.4.9",
25368
+ lefthook: "^2.1.0",
25291
25369
  typescript: "^5.9.3"
25292
25370
  },
25293
25371
  repository: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "0.55.4",
3
+ "version": "0.56.0",
4
4
  "description": "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
5
5
  "main": "core/index.ts",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "release:minor": "node scripts/release.js minor",
19
19
  "release:major": "node scripts/release.js major",
20
20
  "postinstall": "node scripts/postinstall.js",
21
+ "prepare": "lefthook install",
21
22
  "update-commands": "bun -e \"const installer = require('./core/infrastructure/command-installer'); installer.syncCommands().then(r => console.log('Commands updated:', r)).catch(e => console.error('Error:', e.message))\"",
22
23
  "install-global": "./scripts/install.sh",
23
24
  "update": "./scripts/update.sh",
@@ -67,6 +68,7 @@
67
68
  "@types/bun": "latest",
68
69
  "@types/chokidar": "^2.1.7",
69
70
  "@types/prompts": "^2.4.9",
71
+ "lefthook": "^2.1.0",
70
72
  "typescript": "^5.9.3"
71
73
  },
72
74
  "repository": {