shrinker-ai 0.1.0 โ†’ 0.3.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/src/cli.js CHANGED
@@ -9,24 +9,24 @@ import { formatMeasurements, measure } from "./metrics/measure.js";
9
9
  import { openStatsDashboard, writeStatsDashboard } from "./metrics/dashboard.js";
10
10
  import { defaultStatsPath, formatStats, formatStatsChart, getStats, recordRun } from "./metrics/stats-store.js";
11
11
  function usage() {
12
- return `Usage:
13
- shrinker <command> [args...]
14
- shrinker exec [options] [--] <command> [args...]
15
- shrinker pipe [options]
16
- shrinker stats [--json] [--chart] [--dashboard]
17
- shrinker last [--path]
18
- shrinker raw <capture-id> [--path]
19
- shrinker help
20
-
21
- Options:
22
- --kind <auto|git-status|git-diff|git-log|git-list|npm|tail|find|rg|docker|kubectl|cat|gh|test|log>
23
- --max-lines <number> default: 120
24
- --per-file-lines <number> default: 40
25
- --raw bypass filtering
26
- --metrics print per-run savings and duration
27
- --no-save do not save omitted raw output
28
- --no-stats do not record this run
29
- --dashboard write a browser dashboard to ~/.shrinker/dashboard.html
12
+ return `Usage:
13
+ shrinker <command> [args...]
14
+ shrinker exec [options] [--] <command> [args...]
15
+ shrinker pipe [options]
16
+ shrinker stats [--json] [--chart] [--dashboard]
17
+ shrinker last [--path]
18
+ shrinker raw <capture-id> [--path]
19
+ shrinker help
20
+
21
+ Options:
22
+ --kind <auto|git-status|git-diff|git-log|git-list|npm|tail|find|rg|docker|kubectl|cat|gh|test|log>
23
+ --max-lines <number> default: 120
24
+ --per-file-lines <number> default: 40
25
+ --raw bypass filtering
26
+ --metrics print per-run savings and duration
27
+ --no-save do not save omitted raw output
28
+ --no-stats do not record this run
29
+ --dashboard write a browser dashboard to ~/.shrinker/dashboard.html
30
30
  --help`;
31
31
  }
32
32
  function parsePositiveInteger(value, option) {
@@ -45,14 +45,14 @@ function makeChart(summary) {
45
45
  if (points.length === 0) {
46
46
  return `<div class="empty-chart">No recorded runs in the last 30 days.</div>`;
47
47
  }
48
- return `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Daily estimated tokens saved over the last 30 days">
49
- <text class="axis-title" x="18" y="${top + plotHeight / 2}" transform="rotate(-90 18 ${top + plotHeight / 2})">Tokens saved</text>
50
- ${grid}
51
- <line class="axis" x1="${left}" y1="${top + plotHeight}" x2="${width - right}" y2="${top + plotHeight}" />
52
- <polyline class="trend" points="${line}" />
53
- ${dots}
54
- ${labels}
55
- <text class="axis-title" x="${left + plotWidth / 2}" y="${height - 6}" text-anchor="middle">Date</text>
48
+ return `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Daily estimated tokens saved over the last 30 days">
49
+ <text class="axis-title" x="18" y="${top + plotHeight / 2}" transform="rotate(-90 18 ${top + plotHeight / 2})">Tokens saved</text>
50
+ ${grid}
51
+ <line class="axis" x1="${left}" y1="${top + plotHeight}" x2="${width - right}" y2="${top + plotHeight}" />
52
+ <polyline class="trend" points="${line}" />
53
+ ${dots}
54
+ ${labels}
55
+ <text class="axis-title" x="${left + plotWidth / 2}" y="${height - 6}" text-anchor="middle">Date</text>
56
56
  </svg>`;
57
57
  }
58
58
  export function defaultDashboardPath(databasePath) {
@@ -62,63 +62,63 @@ export function writeStatsDashboard(summary, outputPath = defaultDashboardPath(s
62
62
  const filters = summary.byFilter.length === 0
63
63
  ? `<p class="muted">No filter data yet.</p>`
64
64
  : summary.byFilter.map((row) => `<div class="filter-row"><span>${escapeHtml(row.filterKind)}</span><strong>${formatInteger(row.estimatedTokensSaved)}</strong><small>${row.reductionPercent}% reduction</small></div>`).join("");
65
- const html = `<!doctype html>
66
- <html lang="en">
67
- <head>
68
- <meta charset="utf-8">
69
- <meta name="viewport" content="width=device-width, initial-scale=1">
70
- <title>Shrinker stats dashboard</title>
71
- <style>
72
- :root { color-scheme: light; --ink: #17202a; --muted: #66727f; --line: #dce3e8; --blue: #2774d9; --blue-soft: #eaf2ff; --green: #16856b; --surface: #ffffff; --background: #f4f7fa; }
73
- * { box-sizing: border-box; }
74
- body { margin: 0; color: var(--ink); background: var(--background); font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif; }
75
- main { max-width: 1180px; margin: 0 auto; padding: 42px 28px 56px; }
76
- .eyebrow { margin: 0 0 8px; color: var(--blue); font-size: 12px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
77
- h1 { margin: 0; font-size: clamp(28px, 4vw, 44px); letter-spacing: -.03em; }
78
- .subtitle { margin: 8px 0 30px; color: var(--muted); }
79
- .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 20px; }
80
- .card, .panel { border: 1px solid var(--line); border-radius: 8px; background: var(--surface); box-shadow: 0 8px 24px #26394d0d; }
81
- .card { padding: 18px 20px; }
82
- .card label { display: block; color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
83
- .card strong { display: block; margin-top: 6px; font-size: 27px; }
84
- .panel { padding: 22px; }
85
- .panel h2 { margin: 0 0 4px; font-size: 18px; }
86
- .panel p { margin: 0 0 14px; color: var(--muted); }
87
- .chart { overflow: hidden; }
88
- svg { display: block; width: 100%; min-width: 620px; height: auto; }
89
- .chart { overflow-x: auto; }
90
- .grid { stroke: var(--line); stroke-width: 1; }
91
- .axis { stroke: #9aa9b5; stroke-width: 1; }
92
- .axis-label, .axis-title { fill: var(--muted); font-size: 12px; }
93
- .trend { fill: none; stroke: var(--blue); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
94
- .point { fill: var(--surface); stroke: var(--blue); stroke-width: 3; }
95
- .point:hover { fill: var(--blue); r: 6; }
96
- .lower { display: grid; grid-template-columns: 1.2fr .8fr; gap: 20px; margin-top: 20px; }
97
- .filter-row { display: grid; grid-template-columns: 1fr auto auto; gap: 18px; align-items: baseline; padding: 11px 0; border-bottom: 1px solid var(--line); }
98
- .filter-row:last-child { border-bottom: 0; }
99
- .filter-row small { color: var(--green); }
100
- .muted { color: var(--muted); }
101
- @media (max-width: 720px) { main { padding: 28px 16px 40px; } .cards, .lower { grid-template-columns: 1fr; } .card strong { font-size: 24px; } }
102
- </style>
103
- </head>
104
- <body>
105
- <main>
106
- <p class="eyebrow">Local activity</p>
107
- <h1>Shrinker stats</h1>
108
- <p class="subtitle">Token reduction over the last 30 days</p>
109
- <section class="cards">
110
- <div class="card"><label>All-time saved</label><strong>${formatInteger(summary.total.estimatedTokensSaved)}</strong></div>
111
- <div class="card"><label>Runs this week</label><strong>${formatInteger(summary.last7Days.runs)}</strong></div>
112
- <div class="card"><label>Average reduction</label><strong>${summary.total.reductionPercent}%</strong></div>
113
- </section>
114
- <section class="panel chart"><h2>Tokens saved over time</h2><p>Estimated savings from recorded command runs.</p>${makeChart(summary)}</section>
115
- <section class="lower">
116
- <section class="panel"><h2>By filter</h2><p>Where the savings come from.</p>${filters}</section>
117
- <section class="panel"><h2>Storage</h2><p>Stats stay on this machine.</p><p class="muted">${escapeHtml(summary.databasePath)}</p></section>
118
- </section>
119
- </main>
120
- </body>
121
- </html>
65
+ const html = `<!doctype html>
66
+ <html lang="en">
67
+ <head>
68
+ <meta charset="utf-8">
69
+ <meta name="viewport" content="width=device-width, initial-scale=1">
70
+ <title>Shrinker stats dashboard</title>
71
+ <style>
72
+ :root { color-scheme: light; --ink: #17202a; --muted: #66727f; --line: #dce3e8; --blue: #2774d9; --blue-soft: #eaf2ff; --green: #16856b; --surface: #ffffff; --background: #f4f7fa; }
73
+ * { box-sizing: border-box; }
74
+ body { margin: 0; color: var(--ink); background: var(--background); font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif; }
75
+ main { max-width: 1180px; margin: 0 auto; padding: 42px 28px 56px; }
76
+ .eyebrow { margin: 0 0 8px; color: var(--blue); font-size: 12px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
77
+ h1 { margin: 0; font-size: clamp(28px, 4vw, 44px); letter-spacing: -.03em; }
78
+ .subtitle { margin: 8px 0 30px; color: var(--muted); }
79
+ .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 20px; }
80
+ .card, .panel { border: 1px solid var(--line); border-radius: 8px; background: var(--surface); box-shadow: 0 8px 24px #26394d0d; }
81
+ .card { padding: 18px 20px; }
82
+ .card label { display: block; color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
83
+ .card strong { display: block; margin-top: 6px; font-size: 27px; }
84
+ .panel { padding: 22px; }
85
+ .panel h2 { margin: 0 0 4px; font-size: 18px; }
86
+ .panel p { margin: 0 0 14px; color: var(--muted); }
87
+ .chart { overflow: hidden; }
88
+ svg { display: block; width: 100%; min-width: 620px; height: auto; }
89
+ .chart { overflow-x: auto; }
90
+ .grid { stroke: var(--line); stroke-width: 1; }
91
+ .axis { stroke: #9aa9b5; stroke-width: 1; }
92
+ .axis-label, .axis-title { fill: var(--muted); font-size: 12px; }
93
+ .trend { fill: none; stroke: var(--blue); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
94
+ .point { fill: var(--surface); stroke: var(--blue); stroke-width: 3; }
95
+ .point:hover { fill: var(--blue); r: 6; }
96
+ .lower { display: grid; grid-template-columns: 1.2fr .8fr; gap: 20px; margin-top: 20px; }
97
+ .filter-row { display: grid; grid-template-columns: 1fr auto auto; gap: 18px; align-items: baseline; padding: 11px 0; border-bottom: 1px solid var(--line); }
98
+ .filter-row:last-child { border-bottom: 0; }
99
+ .filter-row small { color: var(--green); }
100
+ .muted { color: var(--muted); }
101
+ @media (max-width: 720px) { main { padding: 28px 16px 40px; } .cards, .lower { grid-template-columns: 1fr; } .card strong { font-size: 24px; } }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <main>
106
+ <p class="eyebrow">Local activity</p>
107
+ <h1>Shrinker stats</h1>
108
+ <p class="subtitle">Token reduction over the last 30 days</p>
109
+ <section class="cards">
110
+ <div class="card"><label>All-time saved</label><strong>${formatInteger(summary.total.estimatedTokensSaved)}</strong></div>
111
+ <div class="card"><label>Runs this week</label><strong>${formatInteger(summary.last7Days.runs)}</strong></div>
112
+ <div class="card"><label>Average reduction</label><strong>${summary.total.reductionPercent}%</strong></div>
113
+ </section>
114
+ <section class="panel chart"><h2>Tokens saved over time</h2><p>Estimated savings from recorded command runs.</p>${makeChart(summary)}</section>
115
+ <section class="lower">
116
+ <section class="panel"><h2>By filter</h2><p>Where the savings come from.</p>${filters}</section>
117
+ <section class="panel"><h2>Storage</h2><p>Stats stay on this machine.</p><p class="muted">${escapeHtml(summary.databasePath)}</p></section>
118
+ </section>
119
+ </main>
120
+ </body>
121
+ </html>
122
122
  `;
123
123
  mkdirSync(path.dirname(outputPath), { recursive: true, mode: 0o700 });
124
124
  writeFileSync(outputPath, html, "utf8");
@@ -8,27 +8,27 @@ export function defaultStatsPath() {
8
8
  function openDatabase(databasePath) {
9
9
  mkdirSync(path.dirname(databasePath), { recursive: true, mode: 0o700 });
10
10
  const database = new DatabaseSync(databasePath);
11
- database.exec(`
12
- PRAGMA journal_mode = WAL;
13
- PRAGMA busy_timeout = 2000;
14
- CREATE TABLE IF NOT EXISTS runs (
15
- id INTEGER PRIMARY KEY,
16
- created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
17
- mode TEXT NOT NULL,
18
- filter_kind TEXT NOT NULL,
19
- command_name TEXT NOT NULL,
20
- raw_bytes INTEGER NOT NULL,
21
- output_bytes INTEGER NOT NULL,
22
- raw_estimated_tokens INTEGER NOT NULL,
23
- output_estimated_tokens INTEGER NOT NULL,
24
- estimated_tokens_saved INTEGER NOT NULL,
25
- reduction_percent INTEGER NOT NULL,
26
- duration_ms INTEGER,
27
- omitted INTEGER NOT NULL,
28
- exit_code INTEGER
29
- );
30
- CREATE INDEX IF NOT EXISTS runs_created_at_idx ON runs(created_at);
31
- CREATE INDEX IF NOT EXISTS runs_filter_kind_idx ON runs(filter_kind);
11
+ database.exec(`
12
+ PRAGMA journal_mode = WAL;
13
+ PRAGMA busy_timeout = 2000;
14
+ CREATE TABLE IF NOT EXISTS runs (
15
+ id INTEGER PRIMARY KEY,
16
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
17
+ mode TEXT NOT NULL,
18
+ filter_kind TEXT NOT NULL,
19
+ command_name TEXT NOT NULL,
20
+ raw_bytes INTEGER NOT NULL,
21
+ output_bytes INTEGER NOT NULL,
22
+ raw_estimated_tokens INTEGER NOT NULL,
23
+ output_estimated_tokens INTEGER NOT NULL,
24
+ estimated_tokens_saved INTEGER NOT NULL,
25
+ reduction_percent INTEGER NOT NULL,
26
+ duration_ms INTEGER,
27
+ omitted INTEGER NOT NULL,
28
+ exit_code INTEGER
29
+ );
30
+ CREATE INDEX IF NOT EXISTS runs_created_at_idx ON runs(created_at);
31
+ CREATE INDEX IF NOT EXISTS runs_filter_kind_idx ON runs(filter_kind);
32
32
  `);
33
33
  return database;
34
34
  }
@@ -36,12 +36,12 @@ export function recordRun(statistic, databasePath = defaultStatsPath()) {
36
36
  const database = openDatabase(databasePath);
37
37
  try {
38
38
  database
39
- .prepare(`
40
- INSERT INTO runs (
41
- mode, filter_kind, command_name, raw_bytes, output_bytes,
42
- raw_estimated_tokens, output_estimated_tokens, estimated_tokens_saved,
43
- reduction_percent, duration_ms, omitted, exit_code
44
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
39
+ .prepare(`
40
+ INSERT INTO runs (
41
+ mode, filter_kind, command_name, raw_bytes, output_bytes,
42
+ raw_estimated_tokens, output_estimated_tokens, estimated_tokens_saved,
43
+ reduction_percent, duration_ms, omitted, exit_code
44
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
45
45
  `)
46
46
  .run(statistic.mode, statistic.filterKind, statistic.commandName, statistic.measurements.rawBytes, statistic.measurements.outputBytes, statistic.measurements.rawEstimatedTokens, statistic.measurements.outputEstimatedTokens, statistic.measurements.estimatedTokensSaved, statistic.measurements.reductionPercent, statistic.durationMs ?? null, statistic.omitted ? 1 : 0, statistic.exitCode ?? null);
47
47
  }
@@ -61,11 +61,11 @@ function toStatsRow(row, filterKind = "all") {
61
61
  reductionPercent: raw === 0 ? 0 : Math.max(0, Math.round((1 - output / raw) * 100)),
62
62
  };
63
63
  }
64
- const AGGREGATE = `
65
- COUNT(*) AS runs,
66
- COALESCE(SUM(raw_estimated_tokens), 0) AS raw_tokens,
67
- COALESCE(SUM(output_estimated_tokens), 0) AS output_tokens,
68
- COALESCE(SUM(estimated_tokens_saved), 0) AS tokens_saved
64
+ const AGGREGATE = `
65
+ COUNT(*) AS runs,
66
+ COALESCE(SUM(raw_estimated_tokens), 0) AS raw_tokens,
67
+ COALESCE(SUM(output_estimated_tokens), 0) AS output_tokens,
68
+ COALESCE(SUM(estimated_tokens_saved), 0) AS tokens_saved
69
69
  `;
70
70
  export function getStats(databasePath = defaultStatsPath()) {
71
71
  const database = openDatabase(databasePath);
@@ -77,20 +77,20 @@ export function getStats(databasePath = defaultStatsPath()) {
77
77
  .prepare(`SELECT ${AGGREGATE} FROM runs WHERE created_at >= datetime('now', '-7 days')`)
78
78
  .get();
79
79
  const byFilter = database
80
- .prepare(`
81
- SELECT filter_kind, ${AGGREGATE}
82
- FROM runs
83
- GROUP BY filter_kind
84
- ORDER BY tokens_saved DESC, filter_kind ASC
80
+ .prepare(`
81
+ SELECT filter_kind, ${AGGREGATE}
82
+ FROM runs
83
+ GROUP BY filter_kind
84
+ ORDER BY tokens_saved DESC, filter_kind ASC
85
85
  `)
86
86
  .all();
87
87
  const daily = database
88
- .prepare(`
89
- SELECT substr(created_at, 1, 10) AS date, ${AGGREGATE}
90
- FROM runs
91
- WHERE created_at >= datetime('now', '-30 days')
92
- GROUP BY substr(created_at, 1, 10)
93
- ORDER BY date ASC
88
+ .prepare(`
89
+ SELECT substr(created_at, 1, 10) AS date, ${AGGREGATE}
90
+ FROM runs
91
+ WHERE created_at >= datetime('now', '-30 days')
92
+ GROUP BY substr(created_at, 1, 10)
93
+ ORDER BY date ASC
94
94
  `)
95
95
  .all();
96
96
  return {
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PACKAGE_NAME="shrinker-ai"
5
+ REGISTRY="https://registry.npmjs.org"
6
+ VERSION=""
7
+ LOCAL=0
8
+ SKIP_NPM_INSTALL=0
9
+ SKIP_BUILD=0
10
+ SKIP_LINK=0
11
+ ENABLE_PROFILE_ROUTING=0
12
+ SKIP_PROFILE=0
13
+ SKIP_AGENT_RULES=0
14
+ COPILOT_ONLY=0
15
+ CLAUDE_ONLY=0
16
+ PROFILE_PATH="${PROFILE_PATH:-$HOME/.zshrc}"
17
+ STEP=0
18
+
19
+ print_message() {
20
+ local emoji="$1"
21
+ local message="$2"
22
+ STEP=$((STEP + 1))
23
+ printf '%d. %s %s\n' "$STEP" "$emoji" "$message"
24
+ }
25
+
26
+ while [[ $# -gt 0 ]]; do
27
+ case "$1" in
28
+ --local) LOCAL=1 ;;
29
+ --package-name) PACKAGE_NAME="${2:-}"; shift ;;
30
+ --registry) REGISTRY="${2:-}"; shift ;;
31
+ --version) VERSION="${2:-}"; shift ;;
32
+ --skip-npm-install) SKIP_NPM_INSTALL=1 ;;
33
+ --skip-build) SKIP_BUILD=1 ;;
34
+ --skip-link) SKIP_LINK=1 ;;
35
+ --enable-profile-routing) ENABLE_PROFILE_ROUTING=1 ;;
36
+ --skip-profile) SKIP_PROFILE=1 ;;
37
+ --skip-agent-rules) SKIP_AGENT_RULES=1 ;;
38
+ --copilot-only) COPILOT_ONLY=1 ;;
39
+ --claude-only) CLAUDE_ONLY=1 ;;
40
+ --profile-path) PROFILE_PATH="${2:-}"; shift ;;
41
+ *) print_message "โŒ" "Unknown option: $1" >&2; exit 1 ;;
42
+ esac
43
+ shift
44
+ done
45
+
46
+ if (( ENABLE_PROFILE_ROUTING == 1 && SKIP_PROFILE == 1 )); then print_message "โŒ" "Use either --enable-profile-routing or --skip-profile, not both." >&2; exit 1; fi
47
+ if (( COPILOT_ONLY == 1 && CLAUDE_ONLY == 1 )); then print_message "โŒ" "Use either --copilot-only or --claude-only, not both." >&2; exit 1; fi
48
+
49
+ if (( LOCAL == 0 )); then
50
+ package_spec="$PACKAGE_NAME"
51
+ [[ -z "$VERSION" ]] || package_spec="$PACKAGE_NAME@$VERSION"
52
+ print_message "๐Ÿ“ฆ" "Installing $package_spec from $REGISTRY..."
53
+ npm install --silent --global "$package_spec" "--registry=$REGISTRY"
54
+ package_root="$(npm root --global)/$PACKAGE_NAME"
55
+ entrypoint="$package_root/integrations/macos/install.sh"
56
+ [[ -f "$entrypoint" ]] || { print_message "โŒ" "Installed package installer not found: $entrypoint" >&2; exit 1; }
57
+ bash "$entrypoint" --local \
58
+ $( (( SKIP_NPM_INSTALL )) && echo --skip-npm-install ) \
59
+ $( (( SKIP_BUILD )) && echo --skip-build ) \
60
+ $( (( SKIP_LINK )) && echo --skip-link ) \
61
+ $( (( ENABLE_PROFILE_ROUTING )) && echo --enable-profile-routing ) \
62
+ $( (( SKIP_PROFILE )) && echo --skip-profile ) \
63
+ $( (( SKIP_AGENT_RULES )) && echo --skip-agent-rules ) \
64
+ $( (( COPILOT_ONLY )) && echo --copilot-only ) \
65
+ $( (( CLAUDE_ONLY )) && echo --claude-only ) \
66
+ --profile-path "$PROFILE_PATH"
67
+ exit 0
68
+ fi
69
+
70
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
71
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
72
+ TEMPLATE_PATH="$REPO_ROOT/templates/agent-rules.md"
73
+ INTEGRATION_PATH="$SCRIPT_DIR/shrinker-profile.zsh"
74
+ BLOCK_START="<!-- shrinker agent rules start -->"
75
+ BLOCK_END="<!-- shrinker agent rules end -->"
76
+
77
+ node_version_raw="$(node -v 2>/dev/null || true)"
78
+ [[ -n "$node_version_raw" ]] || { print_message "โŒ" "Node.js was not found on PATH. Install Node.js 22.13+ first." >&2; exit 1; }
79
+ node_version="${node_version_raw#v}"
80
+ IFS='.' read -r node_major node_minor _ <<< "$node_version"
81
+ (( node_major > 22 || (node_major == 22 && node_minor >= 13) )) || { print_message "โŒ" "Node.js 22.13+ is required. Found $node_version_raw" >&2; exit 1; }
82
+
83
+ set_agent_rules() {
84
+ local target="$1"
85
+ local body="$2"
86
+ local block="$BLOCK_START"$'\n'"$body"$'\n'"$BLOCK_END"
87
+ mkdir -p "$(dirname "$target")"
88
+ if [[ -f "$target" ]] && grep -Fq "$BLOCK_START" "$target" && grep -Fq "$BLOCK_END" "$target"; then
89
+ local block_file
90
+ block_file="$(mktemp "$(dirname "$target")/.shrinker-rules.XXXXXX")"
91
+ printf '%s\n' "$block" > "$block_file"
92
+ awk -v start="$BLOCK_START" -v end="$BLOCK_END" -v block_file="$block_file" '
93
+ function print_block(line) {
94
+ while ((getline line < block_file) > 0) print line
95
+ close(block_file)
96
+ }
97
+ {
98
+ if (index($0, start) > 0) {
99
+ if (!replaced) print_block()
100
+ replaced = 1
101
+ inblock = 1
102
+ next
103
+ }
104
+ if (inblock && index($0, end) > 0) {
105
+ inblock = 0
106
+ next
107
+ }
108
+ if (!inblock) print
109
+ }
110
+ ' "$target" > "$target.tmp"
111
+ rm -f "$block_file"
112
+ mv "$target.tmp" "$target"
113
+ elif [[ -f "$target" ]]; then printf '\n%s\n' "$block" >> "$target"
114
+ else printf '%s\n' "$block" > "$target"; fi
115
+ print_message "๐Ÿ“„" "Installed managed rules in: $target"
116
+ }
117
+
118
+ add_profile_integration() {
119
+ local profile_file="$1"
120
+ mkdir -p "$(dirname "$profile_file")"; touch "$profile_file"
121
+ if ! grep -Fq '# >>> shrinker integration >>>' "$profile_file"; then
122
+ printf '\n# >>> shrinker integration >>>\nsource "%s"\n# <<< shrinker integration <<<\n\n' "$INTEGRATION_PATH" >> "$profile_file"
123
+ fi
124
+ }
125
+
126
+ print_message "๐Ÿ“ฆ" "Installing shrinker from: $REPO_ROOT"
127
+ pushd "$REPO_ROOT" >/dev/null
128
+ if (( SKIP_NPM_INSTALL == 0 )); then
129
+ print_message "๐Ÿ“ฅ" "Installing dependencies..."
130
+ npm install --silent
131
+ fi
132
+ if (( SKIP_BUILD == 0 )); then
133
+ print_message "๐Ÿ—๏ธ" "Building shrinker..."
134
+ npm run build --silent
135
+ fi
136
+ if (( SKIP_LINK == 0 )); then
137
+ print_message "๐Ÿ”—" "Linking shrinker globally..."
138
+ npm link --silent
139
+ fi
140
+ popd >/dev/null
141
+
142
+ if (( SKIP_PROFILE == 0 && ENABLE_PROFILE_ROUTING == 1 )); then add_profile_integration "$PROFILE_PATH"; fi
143
+ if (( SKIP_AGENT_RULES == 0 )); then
144
+ [[ -f "$TEMPLATE_PATH" ]] || { print_message "โŒ" "Agent rules template not found: $TEMPLATE_PATH" >&2; exit 1; }
145
+ rules_body="$(cat "$TEMPLATE_PATH")"
146
+ (( CLAUDE_ONLY )) || set_agent_rules "$HOME/.copilot/copilot-instructions.md" "$rules_body"
147
+ (( COPILOT_ONLY )) || set_agent_rules "$HOME/.claude/CLAUDE.md" "$rules_body"
148
+ fi
149
+
150
+ print_message "โœ…" "Install complete."
151
+ echo ""
152
+ echo "๐Ÿ’ก Try: shrinker help"
@@ -0,0 +1,98 @@
1
+ typeset -gA SHRINK_RULES
2
+ SHRINK_RULES=(
3
+ git "status diff log show reflog branch tag stash"
4
+ npm "test t install i ci ls list"
5
+ docker "ps logs images compose"
6
+ kubectl "get describe logs"
7
+ gh "pr issue run"
8
+ rg "*"
9
+ find "*"
10
+ tail "*"
11
+ cat "*"
12
+ ls "*"
13
+ )
14
+
15
+ typeset -gA SHRINK_OPTION_VALUE_FLAGS
16
+ SHRINK_OPTION_VALUE_FLAGS=(
17
+ git "-C -c --git-dir --work-tree --namespace"
18
+ npm "--prefix --cache --registry --workspace --userconfig -w -C"
19
+ docker "-H --host --context --config"
20
+ kubectl "-n --namespace -o --output --context --kubeconfig --cluster --user"
21
+ gh "-R --repo"
22
+ )
23
+
24
+ _shrink_get_subcommand() {
25
+ local cmd="$1"
26
+ shift
27
+
28
+ local -a args
29
+ args=("$@")
30
+
31
+ local -a value_flags
32
+ value_flags=(${=SHRINK_OPTION_VALUE_FLAGS[$cmd]})
33
+
34
+ local i part
35
+ i=1
36
+ while (( i <= ${#args[@]} )); do
37
+ part="${args[$i]}"
38
+ if [[ -z "$part" ]]; then
39
+ (( i++ ))
40
+ continue
41
+ fi
42
+
43
+ if (( ${value_flags[(Ie)$part]} > 0 )); then
44
+ (( i += 2 ))
45
+ continue
46
+ fi
47
+
48
+ if [[ "$part" == -* ]]; then
49
+ (( i++ ))
50
+ continue
51
+ fi
52
+
53
+ print -r -- "${part:l}"
54
+ return 0
55
+ done
56
+
57
+ print -r -- ""
58
+ }
59
+
60
+ _shrink_should_route() {
61
+ local cmd="$1"
62
+ shift
63
+
64
+ local rules="${SHRINK_RULES[$cmd]}"
65
+ [[ -z "$rules" ]] && return 1
66
+ [[ "$rules" == *"*"* ]] && return 0
67
+
68
+ local subcommand
69
+ subcommand="$(_shrink_get_subcommand "$cmd" "$@")"
70
+ [[ -z "$subcommand" ]] && return 1
71
+
72
+ local -a allowlist
73
+ allowlist=(${=rules})
74
+ (( ${allowlist[(Ie)$subcommand]} > 0 ))
75
+ }
76
+
77
+ _shrink_invoke_or_native() {
78
+ local cmd="$1"
79
+ shift
80
+
81
+ if _shrink_should_route "$cmd" "$@" && command -v shrinker >/dev/null 2>&1; then
82
+ command shrinker "$cmd" "$@"
83
+ return $?
84
+ fi
85
+
86
+ command "$cmd" "$@"
87
+ }
88
+
89
+ git() { _shrink_invoke_or_native git "$@"; }
90
+ npm() { _shrink_invoke_or_native npm "$@"; }
91
+ docker() { _shrink_invoke_or_native docker "$@"; }
92
+ kubectl() { _shrink_invoke_or_native kubectl "$@"; }
93
+ gh() { _shrink_invoke_or_native gh "$@"; }
94
+ rg() { _shrink_invoke_or_native rg "$@"; }
95
+ find() { _shrink_invoke_or_native find "$@"; }
96
+ tail() { _shrink_invoke_or_native tail "$@"; }
97
+ cat() { _shrink_invoke_or_native cat "$@"; }
98
+ ls() { _shrink_invoke_or_native ls "$@"; }
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ INSTALL_DIR="${HOME}/.shrinker-src"
5
+ REMOVE_INSTALL_DIR=0
6
+ SKIP_UNLINK=0
7
+ SKIP_AGENT_RULES=0
8
+ COPILOT_ONLY=0
9
+ CLAUDE_ONLY=0
10
+ PROFILE_PATH="${PROFILE_PATH:-$HOME/.zshrc}"
11
+ UNINSTALL_STEP=0
12
+
13
+ print_step() {
14
+ local emoji="$1" message="$2"
15
+ UNINSTALL_STEP=$((UNINSTALL_STEP + 1))
16
+ printf '%d. %s %s\n' "$UNINSTALL_STEP" "$emoji" "$message"
17
+ }
18
+
19
+ while [[ $# -gt 0 ]]; do
20
+ case "$1" in
21
+ --skip-unlink) SKIP_UNLINK=1 ;;
22
+ --skip-agent-rules) SKIP_AGENT_RULES=1 ;;
23
+ --copilot-only) COPILOT_ONLY=1 ;;
24
+ --claude-only) CLAUDE_ONLY=1 ;;
25
+ --profile-path) PROFILE_PATH="${2:-}"; shift ;;
26
+ --install-dir) INSTALL_DIR="${2:-}"; shift ;;
27
+ --remove-install-dir) REMOVE_INSTALL_DIR=1 ;;
28
+ *) echo "Unknown option: $1" >&2; exit 1 ;;
29
+ esac
30
+ shift
31
+ done
32
+
33
+ if (( COPILOT_ONLY == 1 && CLAUDE_ONLY == 1 )); then echo "Use either --copilot-only or --claude-only, not both." >&2; exit 1; fi
34
+ BLOCK_START="<!-- shrinker agent rules start -->"
35
+ BLOCK_END="<!-- shrinker agent rules end -->"
36
+
37
+ remove_agent_rules() {
38
+ local target="$1"
39
+ [[ -f "$target" ]] || return 0
40
+ awk -v start="$BLOCK_START" -v end="$BLOCK_END" 'BEGIN{inblock=0} {if(index($0,start)>0){inblock=1;next} if(inblock&&index($0,end)>0){inblock=0;next} if(!inblock)print}' "$target" > "$target.tmp"
41
+ mv "$target.tmp" "$target"
42
+ }
43
+
44
+ remove_profile_integration() {
45
+ local profile_file="$1"
46
+ [[ -f "$profile_file" ]] || return 0
47
+ awk -v start='# >>> shrinker integration >>>' -v end='# <<< shrinker integration <<<' 'BEGIN{inblock=0} {if(index($0,start)>0){inblock=1;next} if(inblock&&index($0,end)>0){inblock=0;next} if(!inblock)print}' "$profile_file" > "$profile_file.tmp"
48
+ mv "$profile_file.tmp" "$profile_file"
49
+ }
50
+
51
+ if (( SKIP_UNLINK == 0 )); then
52
+ print_step "๐Ÿ”—" "Unlinking shrinker globally..."
53
+ npm unlink --silent --global shrinker-ai
54
+ else
55
+ print_step "โญ๏ธ" "Skipped global npm unlink."
56
+ fi
57
+ print_step "๐Ÿ”ง" "Removing shell profile integration..."
58
+ remove_profile_integration "$PROFILE_PATH"
59
+ if (( SKIP_AGENT_RULES == 0 )); then
60
+ (( CLAUDE_ONLY )) || remove_agent_rules "$HOME/.copilot/copilot-instructions.md"
61
+ (( COPILOT_ONLY )) || remove_agent_rules "$HOME/.claude/CLAUDE.md"
62
+ else
63
+ print_step "โญ๏ธ" "Skipped managed agent rules."
64
+ fi
65
+ print_step "โœ…" "Uninstall complete."
66
+
67
+ if (( REMOVE_INSTALL_DIR == 1 )) && [[ -d "$INSTALL_DIR" ]]; then
68
+ rm -rf "$INSTALL_DIR"
69
+ print_step "๐Ÿงน" "Removed install directory: $INSTALL_DIR"
70
+ fi