@tanagram/cli 0.4.13 → 0.4.18

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/install.js CHANGED
@@ -4,11 +4,134 @@ const { execSync } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
+ const https = require('https');
7
8
  const pkg = require('./package.json');
8
9
 
9
10
  const GO_VERSION = '1.21.0';
10
11
  const GO_CACHE_DIR = path.join(os.homedir(), '.tanagram', `go-${GO_VERSION}`);
11
12
 
13
+ // Map Node.js platform/arch to GoReleaser naming
14
+ function getPlatformInfo() {
15
+ const platform = os.platform();
16
+ const arch = os.arch();
17
+
18
+ const platformMap = {
19
+ 'darwin': 'darwin',
20
+ 'linux': 'linux',
21
+ 'win32': 'windows'
22
+ };
23
+
24
+ const archMap = {
25
+ 'x64': 'amd64',
26
+ 'arm64': 'arm64'
27
+ };
28
+
29
+ const nodePlatformDir = {
30
+ 'darwin': arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64',
31
+ 'linux': arch === 'arm64' ? 'linux-arm64' : 'linux-x64',
32
+ 'win32': 'win32-x64'
33
+ };
34
+
35
+ return {
36
+ goos: platformMap[platform] || platform,
37
+ goarch: archMap[arch] || arch,
38
+ binaryName: platform === 'win32' ? 'tanagram.exe' : 'tanagram',
39
+ platformDir: nodePlatformDir[platform]
40
+ };
41
+ }
42
+
43
+ // Check for prebuilt binary bundled with npm package
44
+ function checkPrebuiltBinary() {
45
+ const { platformDir, binaryName } = getPlatformInfo();
46
+
47
+ // Check in dist/npm directory (where CI puts them)
48
+ const distPath = path.join(__dirname, 'dist', 'npm', platformDir, binaryName);
49
+ if (fs.existsSync(distPath)) {
50
+ console.log(`✓ Found prebuilt binary for ${platformDir}`);
51
+ return distPath;
52
+ }
53
+
54
+ // Check in platform directory at package root
55
+ const rootPath = path.join(__dirname, platformDir, binaryName);
56
+ if (fs.existsSync(rootPath)) {
57
+ console.log(`✓ Found prebuilt binary for ${platformDir}`);
58
+ return rootPath;
59
+ }
60
+
61
+ return null;
62
+ }
63
+
64
+ // Download prebuilt binary from GitHub releases
65
+ async function downloadPrebuiltBinary() {
66
+ const { goos, goarch, binaryName } = getPlatformInfo();
67
+ const version = pkg.version;
68
+ const ext = goos === 'windows' ? 'zip' : 'tar.gz';
69
+ const releaseTag = `cli-v${version}`;
70
+ const assetName = `tanagram_${version}_${goos}_${goarch}.${ext}`;
71
+
72
+ const url = `https://github.com/tanagram/monorepo/releases/download/${releaseTag}/${assetName}`;
73
+ const tmpDir = path.join(os.tmpdir(), `tanagram-${Date.now()}`);
74
+ const archivePath = path.join(tmpDir, assetName);
75
+
76
+ console.log(`📦 Downloading prebuilt binary from GitHub releases...`);
77
+ console.log(` ${url}`);
78
+
79
+ return new Promise((resolve, reject) => {
80
+ fs.mkdirSync(tmpDir, { recursive: true });
81
+
82
+ const download = (downloadUrl, redirectCount = 0) => {
83
+ if (redirectCount > 5) {
84
+ reject(new Error('Too many redirects'));
85
+ return;
86
+ }
87
+
88
+ https.get(downloadUrl, (response) => {
89
+ if (response.statusCode === 302 || response.statusCode === 301) {
90
+ download(response.headers.location, redirectCount + 1);
91
+ return;
92
+ }
93
+
94
+ if (response.statusCode !== 200) {
95
+ reject(new Error(`Download failed: HTTP ${response.statusCode}`));
96
+ return;
97
+ }
98
+
99
+ const file = fs.createWriteStream(archivePath);
100
+ response.pipe(file);
101
+
102
+ file.on('finish', () => {
103
+ file.close();
104
+
105
+ try {
106
+ // Extract the archive
107
+ if (ext === 'zip') {
108
+ execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`, { stdio: 'pipe' });
109
+ } else {
110
+ execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { stdio: 'pipe' });
111
+ }
112
+
113
+ const extractedBinary = path.join(tmpDir, binaryName);
114
+ if (fs.existsSync(extractedBinary)) {
115
+ resolve(extractedBinary);
116
+ } else {
117
+ reject(new Error('Binary not found in archive'));
118
+ }
119
+ } catch (err) {
120
+ reject(new Error(`Failed to extract: ${err.message}`));
121
+ }
122
+ });
123
+
124
+ file.on('error', (err) => {
125
+ fs.unlinkSync(archivePath);
126
+ reject(err);
127
+ });
128
+ }).on('error', reject);
129
+ };
130
+
131
+ download(url);
132
+ });
133
+ }
134
+
12
135
  // Check if Go is already installed locally
13
136
  function checkLocalGo() {
14
137
  try {
@@ -64,13 +187,9 @@ async function downloadGo() {
64
187
  }
65
188
  }
66
189
 
67
- // Build the binary
190
+ // Build the binary from source
68
191
  function buildBinary(goCommand) {
69
- const platform = os.platform();
70
- const arch = os.arch();
71
- const goos = platform === 'win32' ? 'windows' : platform;
72
- const goarch = arch === 'x64' ? 'amd64' : arch === 'arm64' ? 'arm64' : arch;
73
- const binaryName = platform === 'win32' ? 'tanagram.exe' : 'tanagram';
192
+ const { goos, goarch, binaryName } = getPlatformInfo();
74
193
  const binaryPath = path.join(__dirname, 'bin', binaryName);
75
194
 
76
195
  const binDir = path.join(__dirname, 'bin');
@@ -78,7 +197,7 @@ function buildBinary(goCommand) {
78
197
  fs.mkdirSync(binDir, { recursive: true });
79
198
  }
80
199
 
81
- console.log('🔧 Building Tanagram CLI...');
200
+ console.log('🔧 Building Tanagram CLI from source...');
82
201
 
83
202
  try {
84
203
  const ldflags = `-X 'main.Version=${pkg.version}'`;
@@ -92,17 +211,37 @@ function buildBinary(goCommand) {
92
211
  }
93
212
  });
94
213
 
95
- if (platform !== 'win32') {
214
+ if (os.platform() !== 'win32') {
96
215
  fs.chmodSync(binaryPath, '755');
97
216
  }
98
217
 
99
- console.log('✓ Tanagram CLI installed successfully');
218
+ console.log('✓ Tanagram CLI built successfully');
100
219
  return binaryPath;
101
220
  } catch (error) {
102
221
  throw new Error(`Build failed: ${error.message}`);
103
222
  }
104
223
  }
105
224
 
225
+ // Copy prebuilt binary to bin directory
226
+ function installPrebuiltBinary(sourcePath) {
227
+ const { binaryName } = getPlatformInfo();
228
+ const binDir = path.join(__dirname, 'bin');
229
+ const targetPath = path.join(binDir, binaryName);
230
+
231
+ if (!fs.existsSync(binDir)) {
232
+ fs.mkdirSync(binDir, { recursive: true });
233
+ }
234
+
235
+ fs.copyFileSync(sourcePath, targetPath);
236
+
237
+ if (os.platform() !== 'win32') {
238
+ fs.chmodSync(targetPath, '755');
239
+ }
240
+
241
+ console.log('✓ Tanagram CLI installed successfully');
242
+ return targetPath;
243
+ }
244
+
106
245
  function isCIEnvironment() {
107
246
  return (
108
247
  process.env.CI === 'true' ||
@@ -147,23 +286,38 @@ function configureEditorHooks(binaryPath) {
147
286
  // Main installation flow
148
287
  (async () => {
149
288
  try {
150
- // 1. Check if Go is already installed locally
151
- let goCommand = checkLocalGo();
289
+ let binaryPath;
152
290
 
153
- // 2. If not, check if we have a cached go-bin download
154
- if (!goCommand) {
155
- goCommand = checkCachedGo();
156
- }
291
+ // 1. Check for prebuilt binary bundled with npm package
292
+ const prebuiltPath = checkPrebuiltBinary();
293
+ if (prebuiltPath) {
294
+ binaryPath = installPrebuiltBinary(prebuiltPath);
295
+ } else {
296
+ // 2. Try to download prebuilt binary from GitHub releases
297
+ try {
298
+ console.log('Looking for prebuilt binary...');
299
+ const downloadedPath = await downloadPrebuiltBinary();
300
+ binaryPath = installPrebuiltBinary(downloadedPath);
301
+ } catch (downloadErr) {
302
+ console.log(`Prebuilt binary not available: ${downloadErr.message}`);
303
+ console.log('Falling back to building from source...');
157
304
 
158
- // 3. If still no Go, download via go-bin and cache it
159
- if (!goCommand) {
160
- goCommand = await downloadGo();
161
- }
305
+ // 3. Fall back to building from source
306
+ let goCommand = checkLocalGo();
307
+
308
+ if (!goCommand) {
309
+ goCommand = checkCachedGo();
310
+ }
162
311
 
163
- // 4. Build the binary
164
- const binaryPath = buildBinary(goCommand);
312
+ if (!goCommand) {
313
+ goCommand = await downloadGo();
314
+ }
315
+
316
+ binaryPath = buildBinary(goCommand);
317
+ }
318
+ }
165
319
 
166
- // 5. Configure hooks
320
+ // Configure hooks
167
321
  configureEditorHooks(binaryPath);
168
322
 
169
323
  process.exit(0);
package/main.go CHANGED
@@ -2,19 +2,30 @@ package main
2
2
 
3
3
  import (
4
4
  "encoding/json"
5
+ "flag"
5
6
  "fmt"
6
7
  "io"
8
+ "log/slog"
7
9
  "os"
10
+ "path/filepath"
11
+ "strings"
8
12
 
9
13
  "github.com/tanagram/cli/commands"
10
14
  "github.com/tanagram/cli/metrics"
11
15
  "github.com/tanagram/cli/tui"
12
16
  "github.com/tanagram/cli/utils"
17
+ "golang.org/x/term"
13
18
  )
14
19
 
15
20
  // Version is set in install.js via `-X` ldflags
16
21
  var Version = "dev"
17
22
 
23
+ var (
24
+ flagLogLevel = flag.String("log-level", "info", "log level: debug, info, warn, error")
25
+ flagLogFormat = flag.String("log-format", "text", "log format: text or json")
26
+ flagLogFile = flag.String("log-file", "", "log output file path (defaults to stderr if not specified)")
27
+ )
28
+
18
29
  func main() {
19
30
  metrics.SetVersion(Version)
20
31
  metrics.Init()
@@ -30,16 +41,50 @@ func main() {
30
41
  os.Exit(exitCode)
31
42
  }()
32
43
 
44
+ flag.Parse()
45
+
33
46
  // Get subcommand (default to "run" if none provided)
34
47
  subcommand := "run"
35
- if len(os.Args) > 1 {
36
- subcommand = os.Args[1]
48
+ args := flag.Args()
49
+ if len(args) > 0 {
50
+ subcommand = args[0]
37
51
  }
38
52
 
39
53
  metrics.Track("cli.start", map[string]interface{}{
40
54
  "subcommand": subcommand,
41
55
  })
42
56
 
57
+ var logOutput io.Writer = os.Stderr
58
+ if utils.GetParentProcess() == "claude" {
59
+ // We use "exit-code 2" behavior for claude: https://code.claude.com/docs/en/hooks#simple:-exit-code
60
+ // Claude expects communication (i.e. `stop` hook output) to come on `stderr`,
61
+ // Which means we want to send log output (i.e. not output-to-Claude) to `stdout`.
62
+ logOutput = os.Stdout
63
+ }
64
+ if *flagLogFile != "" {
65
+ logPath, logErr := createLogFile(*flagLogFile)
66
+ if logErr != nil {
67
+ slog.Error("Error setting up logging", "error", logErr)
68
+ exitCode = 1
69
+ return
70
+ }
71
+ logFile, logErr := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
72
+ if logErr != nil {
73
+ slog.Error("Error opening log file", "error", logErr)
74
+ exitCode = 1
75
+ return
76
+ }
77
+ defer logFile.Close()
78
+ logOutput = logFile
79
+ }
80
+ isTTY := term.IsTerminal(int(os.Stdout.Fd()))
81
+ logger := newLogger(*flagLogLevel, *flagLogFormat, logOutput, isTTY)
82
+ slog.SetDefault(logger)
83
+
84
+ slog.Info("Running CLI with args",
85
+ "args", os.Args[1:],
86
+ )
87
+
43
88
  // THIS IS A HUGE HACK
44
89
  // Cursor runs its hooks in the ~/.cursor directory as cwd
45
90
  // Claude runs its hooks directly as a subprocess, with your terminal directory as cwd
@@ -54,11 +99,11 @@ func main() {
54
99
  WorkspaceRoots []string `json:"workspace_roots"`
55
100
  }
56
101
  if err := json.Unmarshal(input, &payload); err != nil {
57
- fmt.Fprintf(os.Stderr, "Error parsing input: %v\n", err)
102
+ slog.Error("Error parsing input", "error", err)
58
103
  } else if len(payload.WorkspaceRoots) > 0 {
59
104
  os.Chdir(payload.WorkspaceRoots[0])
60
105
  } else {
61
- fmt.Fprintf(os.Stderr, "No workspace roots found\n")
106
+ slog.Warn("No workspace roots found")
62
107
  }
63
108
  }
64
109
  }
@@ -71,7 +116,7 @@ func main() {
71
116
  })
72
117
  // Auto-setup hooks on first run
73
118
  if err := commands.EnsureHooksConfigured(); err != nil {
74
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
119
+ slog.Error("Failed to configure hooks", "error", err)
75
120
  exitCode = 1
76
121
  return
77
122
  }
@@ -87,7 +132,7 @@ func main() {
87
132
  })
88
133
  // Auto-setup hooks on first run
89
134
  if err := commands.EnsureHooksConfigured(); err != nil {
90
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
135
+ slog.Error("Failed to configure hooks", "error", err)
91
136
  exitCode = 1
92
137
  return
93
138
  }
@@ -98,7 +143,7 @@ func main() {
98
143
  })
99
144
  // Auto-setup hooks on first run
100
145
  if err := commands.EnsureHooksConfigured(); err != nil {
101
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
146
+ slog.Error("Failed to configure hooks", "error", err)
102
147
  exitCode = 1
103
148
  return
104
149
  }
@@ -152,7 +197,7 @@ func main() {
152
197
  })
153
198
  choice, err := tui.RunWelcomeScreen()
154
199
  if err != nil {
155
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
200
+ slog.Error("Welcome screen failed", "error", err)
156
201
  exitCode = 1
157
202
  return
158
203
  }
@@ -177,17 +222,6 @@ func main() {
177
222
  "command": "sync-policies",
178
223
  })
179
224
  err = commands.SyncPolicies()
180
- case "puzzle":
181
- metrics.Track("cli.command.execute", map[string]interface{}{
182
- "command": "puzzle",
183
- })
184
- err := tui.RunPuzzleEditor()
185
- if err != nil {
186
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
187
- exitCode = 1
188
- return
189
- }
190
- return
191
225
  case "version", "-v", "--version":
192
226
  fmt.Println(Version)
193
227
  return
@@ -206,7 +240,7 @@ func main() {
206
240
  "command": subcommand,
207
241
  "error": err.Error(),
208
242
  })
209
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
243
+ slog.Error("Command failed", "command", subcommand, "error", err)
210
244
  exitCode = 1
211
245
  return
212
246
  }
@@ -266,3 +300,58 @@ HOOK WORKFLOW:
266
300
  `
267
301
  fmt.Print(help)
268
302
  }
303
+
304
+ func newLogger(levelStr, format string, output io.Writer, isTTY bool) *slog.Logger {
305
+ var lvl slog.Level
306
+ switch strings.ToLower(levelStr) {
307
+ case "debug":
308
+ lvl = slog.LevelDebug
309
+ case "info":
310
+ lvl = slog.LevelInfo
311
+ case "warn", "warning":
312
+ lvl = slog.LevelWarn
313
+ case "error":
314
+ lvl = slog.LevelError
315
+ default:
316
+ lvl = slog.LevelInfo
317
+ }
318
+
319
+ opts := &slog.HandlerOptions{
320
+ Level: lvl,
321
+ }
322
+
323
+ var h slog.Handler
324
+ switch strings.ToLower(format) {
325
+ case "json":
326
+ h = slog.NewJSONHandler(output, opts)
327
+ default:
328
+ if isTTY {
329
+ opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
330
+ if a.Key == slog.TimeKey || a.Key == slog.LevelKey {
331
+ return slog.Attr{}
332
+ }
333
+ return a
334
+ }
335
+ }
336
+ h = slog.NewTextHandler(output, opts)
337
+ }
338
+
339
+ return slog.New(h)
340
+ }
341
+
342
+ func createLogFile(logFilePath string) (string, error) {
343
+ logPath := logFilePath
344
+ if strings.HasPrefix(logPath, "~/") {
345
+ home, err := os.UserHomeDir()
346
+ if err != nil {
347
+ return "", fmt.Errorf("getting home directory: %w", err)
348
+ }
349
+ logPath = filepath.Join(home, logPath[2:])
350
+ }
351
+
352
+ if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
353
+ return "", fmt.Errorf("creating log directory: %w", err)
354
+ }
355
+
356
+ return logPath, nil
357
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanagram/cli",
3
- "version": "0.4.13",
3
+ "version": "0.4.18",
4
4
  "description": "Tanagram - Catch sloppy code before it ships",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -8,8 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "postinstall": "node install.js",
11
- "test": "go test ./...",
12
- "prepublishOnly": "export $(grep -v '^#' .env.publish | xargs) && node install.js"
11
+ "test": "go test ./..."
13
12
  },
14
13
  "keywords": [
15
14
  "tanagram",
@@ -23,7 +22,8 @@
23
22
  "license": "MIT",
24
23
  "repository": {
25
24
  "type": "git",
26
- "url": "https://github.com/tanagram/cli.git"
25
+ "url": "https://github.com/tanagram/monorepo.git",
26
+ "directory": "cli"
27
27
  },
28
28
  "engines": {
29
29
  "node": ">=14.0.0"
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "files": [
35
35
  "bin/tanagram.js",
36
+ "dist/npm/",
36
37
  "api/",
37
38
  "auth/",
38
39
  "checker/",
package/tui/welcome.go CHANGED
@@ -70,19 +70,7 @@ func (m WelcomeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
70
70
 
71
71
  // View renders the UI
72
72
  func (m WelcomeModel) View() string {
73
- // Load and render the tangram logo from config
74
- var logo string
75
-
76
- pieces, err := LoadTanagramConfig("tanagram-config.json")
77
- if err == nil && len(pieces) > 0 {
78
- // Render using the shared renderer
79
- renderer := NewTanagramRenderer(90, 45)
80
- canvas := renderer.RenderPiecesToCanvas(pieces)
81
- logo = renderer.CanvasToString(canvas, pieces)
82
- } else {
83
- // Fallback to simple text if config not found
84
- logo = "TANAGRAM"
85
- }
73
+ logo := "TANAGRAM"
86
74
 
87
75
  // Title style
88
76
  titleStyle := lipgloss.NewStyle().
package/utils/process.go CHANGED
@@ -30,9 +30,10 @@ func GetParentProcess() string {
30
30
  return
31
31
  }
32
32
 
33
- // Cursor runs hooks via `node` or `zsh` (I've seen both) in ~/.cursor
33
+ // Cursor runs hooks via `node` or a shell (I've seen both) in ~/.cursor
34
34
  // Handle this case and make life easier for users of this function.
35
- if name == "node" || name == "zsh" {
35
+ // `pwsh` is powershell: https://chatgpt.com/share/6927b015-2738-8000-ab6d-792e05c9401b
36
+ if name == "node" || name == "zsh" || name == "bash" || name == "fish" || name == "sh" || strings.Contains(name, "pwsh") {
36
37
  cwd, err := parent.Cwd()
37
38
  if err == nil && strings.Contains(cwd, ".cursor") {
38
39
  parentProcessName = "cursor"