prjct-cli 0.55.3 → 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,5 +1,58 @@
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
+
34
+ ## [0.55.4] - 2026-02-05
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
+
48
+ ### Bug Fixes
49
+
50
+ - remove legacy p.*.md commands on sync
51
+
52
+ ### Tests
53
+
54
+ - Added 11 new tests for output tier functionality
55
+
3
56
  ## [0.55.3] - 2026-02-05
4
57
 
5
58
  ### Bug Fixes
@@ -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
  }
@@ -569,6 +569,36 @@ export class CommandInstaller {
569
569
  }
570
570
  }
571
571
 
572
+ /**
573
+ * Remove legacy p.*.md files from commands root directory
574
+ * These were replaced by the p/ subdirectory structure in v0.50+
575
+ */
576
+ async removeLegacyCommands(): Promise<number> {
577
+ const aiProvider = require('./ai-provider')
578
+ const activeProvider = aiProvider.getActiveProvider()
579
+ const commandsRoot = path.join(activeProvider.configDir, 'commands')
580
+
581
+ let removed = 0
582
+
583
+ try {
584
+ const files = await fs.readdir(commandsRoot)
585
+ const legacyFiles = files.filter((f) => f.startsWith('p.') && f.endsWith('.md'))
586
+
587
+ for (const file of legacyFiles) {
588
+ try {
589
+ await fs.unlink(path.join(commandsRoot, file))
590
+ removed++
591
+ } catch {
592
+ // Ignore errors removing individual files
593
+ }
594
+ }
595
+ } catch {
596
+ // Ignore errors if directory doesn't exist
597
+ }
598
+
599
+ return removed
600
+ }
601
+
572
602
  /**
573
603
  * Sync commands - intelligent update that detects and removes orphans
574
604
  */
@@ -639,9 +669,9 @@ export class CommandInstaller {
639
669
  }
640
670
  }
641
671
 
642
- // Note: We do NOT remove orphaned files
643
- // Legacy commands from older versions are preserved
644
- // to avoid breaking existing workflows
672
+ // Remove legacy p.*.md files from commands root (old naming convention)
673
+ // These were replaced by p/ subdirectory structure
674
+ await this.removeLegacyCommands()
645
675
 
646
676
  return results
647
677
  } catch (error) {
@@ -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
@@ -5952,6 +6029,29 @@ var init_command_installer = __esm({
5952
6029
  throw error;
5953
6030
  }
5954
6031
  }
6032
+ /**
6033
+ * Remove legacy p.*.md files from commands root directory
6034
+ * These were replaced by the p/ subdirectory structure in v0.50+
6035
+ */
6036
+ async removeLegacyCommands() {
6037
+ const aiProvider = (init_ai_provider(), __toCommonJS(ai_provider_exports));
6038
+ const activeProvider = aiProvider.getActiveProvider();
6039
+ const commandsRoot = path16.join(activeProvider.configDir, "commands");
6040
+ let removed = 0;
6041
+ try {
6042
+ const files = await fs16.readdir(commandsRoot);
6043
+ const legacyFiles = files.filter((f) => f.startsWith("p.") && f.endsWith(".md"));
6044
+ for (const file of legacyFiles) {
6045
+ try {
6046
+ await fs16.unlink(path16.join(commandsRoot, file));
6047
+ removed++;
6048
+ } catch {
6049
+ }
6050
+ }
6051
+ } catch {
6052
+ }
6053
+ return removed;
6054
+ }
5955
6055
  /**
5956
6056
  * Sync commands - intelligent update that detects and removes orphans
5957
6057
  */
@@ -6004,6 +6104,7 @@ var init_command_installer = __esm({
6004
6104
  results.errors.push({ file, error: error.message });
6005
6105
  }
6006
6106
  }
6107
+ await this.removeLegacyCommands();
6007
6108
  return results;
6008
6109
  } catch (error) {
6009
6110
  return {
@@ -23907,8 +24008,7 @@ var init_client = __esm({
23907
24008
  }
23908
24009
  this.sdk = new LinearSDK({ apiKey });
23909
24010
  try {
23910
- const viewer = await this.sdk.viewer;
23911
- console.error(`[linear] Connected as ${viewer.name} (${viewer.email})`);
24011
+ await this.sdk.viewer;
23912
24012
  } catch (error) {
23913
24013
  this.sdk = null;
23914
24014
  throw new Error(`Linear connection failed: ${error.message}`);
@@ -25197,7 +25297,7 @@ var require_package = __commonJS({
25197
25297
  "package.json"(exports, module) {
25198
25298
  module.exports = {
25199
25299
  name: "prjct-cli",
25200
- version: "0.55.3",
25300
+ version: "0.56.0",
25201
25301
  description: "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
25202
25302
  main: "core/index.ts",
25203
25303
  bin: {
@@ -25215,6 +25315,7 @@ var require_package = __commonJS({
25215
25315
  "release:minor": "node scripts/release.js minor",
25216
25316
  "release:major": "node scripts/release.js major",
25217
25317
  postinstall: "node scripts/postinstall.js",
25318
+ prepare: "lefthook install",
25218
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))"`,
25219
25320
  "install-global": "./scripts/install.sh",
25220
25321
  update: "./scripts/update.sh",
@@ -25264,6 +25365,7 @@ var require_package = __commonJS({
25264
25365
  "@types/bun": "latest",
25265
25366
  "@types/chokidar": "^2.1.7",
25266
25367
  "@types/prompts": "^2.4.9",
25368
+ lefthook: "^2.1.0",
25267
25369
  typescript: "^5.9.3"
25268
25370
  },
25269
25371
  repository: {
@@ -873,6 +873,29 @@ var CommandInstaller = class {
873
873
  throw error;
874
874
  }
875
875
  }
876
+ /**
877
+ * Remove legacy p.*.md files from commands root directory
878
+ * These were replaced by the p/ subdirectory structure in v0.50+
879
+ */
880
+ async removeLegacyCommands() {
881
+ const aiProvider = (init_ai_provider(), __toCommonJS(ai_provider_exports));
882
+ const activeProvider = aiProvider.getActiveProvider();
883
+ const commandsRoot = import_node_path3.default.join(activeProvider.configDir, "commands");
884
+ let removed = 0;
885
+ try {
886
+ const files = await import_promises.default.readdir(commandsRoot);
887
+ const legacyFiles = files.filter((f) => f.startsWith("p.") && f.endsWith(".md"));
888
+ for (const file of legacyFiles) {
889
+ try {
890
+ await import_promises.default.unlink(import_node_path3.default.join(commandsRoot, file));
891
+ removed++;
892
+ } catch {
893
+ }
894
+ }
895
+ } catch {
896
+ }
897
+ return removed;
898
+ }
876
899
  /**
877
900
  * Sync commands - intelligent update that detects and removes orphans
878
901
  */
@@ -925,6 +948,7 @@ var CommandInstaller = class {
925
948
  results.errors.push({ file, error: error.message });
926
949
  }
927
950
  }
951
+ await this.removeLegacyCommands();
928
952
  return results;
929
953
  } catch (error) {
930
954
  return {
@@ -876,6 +876,29 @@ var CommandInstaller = class {
876
876
  throw error;
877
877
  }
878
878
  }
879
+ /**
880
+ * Remove legacy p.*.md files from commands root directory
881
+ * These were replaced by the p/ subdirectory structure in v0.50+
882
+ */
883
+ async removeLegacyCommands() {
884
+ const aiProvider = (init_ai_provider(), __toCommonJS(ai_provider_exports));
885
+ const activeProvider = aiProvider.getActiveProvider();
886
+ const commandsRoot = import_node_path3.default.join(activeProvider.configDir, "commands");
887
+ let removed = 0;
888
+ try {
889
+ const files = await import_promises.default.readdir(commandsRoot);
890
+ const legacyFiles = files.filter((f) => f.startsWith("p.") && f.endsWith(".md"));
891
+ for (const file of legacyFiles) {
892
+ try {
893
+ await import_promises.default.unlink(import_node_path3.default.join(commandsRoot, file));
894
+ removed++;
895
+ } catch {
896
+ }
897
+ }
898
+ } catch {
899
+ }
900
+ return removed;
901
+ }
879
902
  /**
880
903
  * Sync commands - intelligent update that detects and removes orphans
881
904
  */
@@ -928,6 +951,7 @@ var CommandInstaller = class {
928
951
  results.errors.push({ file, error: error.message });
929
952
  }
930
953
  }
954
+ await this.removeLegacyCommands();
931
955
  return results;
932
956
  } catch (error) {
933
957
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "0.55.3",
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": {