@tanagram/cli 0.4.8 → 0.4.10
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/commands/login.go +184 -5
- package/commands/snapshot.go +24 -2
- package/package.json +1 -1
- package/snapshot/snapshot.go +182 -2
package/commands/login.go
CHANGED
|
@@ -81,14 +81,193 @@ func Login() error {
|
|
|
81
81
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
82
82
|
fmt.Fprintf(w, `
|
|
83
83
|
<!DOCTYPE html>
|
|
84
|
-
<html>
|
|
84
|
+
<html lang="en">
|
|
85
85
|
<head>
|
|
86
86
|
<meta charset="utf-8">
|
|
87
|
-
<
|
|
87
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
88
|
+
<title>Tanagram – Login Successful</title>
|
|
89
|
+
<style>
|
|
90
|
+
:root {
|
|
91
|
+
color-scheme: light dark;
|
|
92
|
+
--bg-color: #f5f5f7;
|
|
93
|
+
--card-bg-color: #ffffff;
|
|
94
|
+
--border-color: rgba(15, 23, 42, 0.08);
|
|
95
|
+
--text-color: #0f172a;
|
|
96
|
+
--muted-text-color: #6b7280;
|
|
97
|
+
--accent-color: #16a34a;
|
|
98
|
+
--accent-soft: rgba(22, 163, 74, 0.1);
|
|
99
|
+
--shadow-soft: 0 18px 45px rgba(15, 23, 42, 0.12);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@media (prefers-color-scheme: dark) {
|
|
103
|
+
:root {
|
|
104
|
+
--bg-color: #020617;
|
|
105
|
+
--card-bg-color: #020617;
|
|
106
|
+
--border-color: rgba(148, 163, 184, 0.28);
|
|
107
|
+
--text-color: #e5e7eb;
|
|
108
|
+
--muted-text-color: #9ca3af;
|
|
109
|
+
--accent-color: #22c55e;
|
|
110
|
+
--accent-soft: rgba(34, 197, 94, 0.15);
|
|
111
|
+
--shadow-soft: 0 18px 45px rgba(0, 0, 0, 0.8);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
*,
|
|
116
|
+
*::before,
|
|
117
|
+
*::after {
|
|
118
|
+
box-sizing: border-box;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
html, body {
|
|
122
|
+
margin: 0;
|
|
123
|
+
padding: 0;
|
|
124
|
+
height: 100%;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
body {
|
|
128
|
+
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, -system-ui, sans-serif;
|
|
129
|
+
background-color: var(--bg-color);
|
|
130
|
+
color: var(--text-color);
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
justify-content: center;
|
|
134
|
+
padding: 24px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.container {
|
|
138
|
+
max-width: 440px;
|
|
139
|
+
width: 100%;
|
|
140
|
+
display: flex;
|
|
141
|
+
flex-direction: column;
|
|
142
|
+
align-items: center;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.card {
|
|
146
|
+
background-color: var(--card-bg-color);
|
|
147
|
+
border-radius: 18px;
|
|
148
|
+
border: 1px solid var(--border-color);
|
|
149
|
+
box-shadow: var(--shadow-soft);
|
|
150
|
+
padding: 28px 26px 24px;
|
|
151
|
+
position: relative;
|
|
152
|
+
overflow: hidden;
|
|
153
|
+
width: 100%;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.card::before {
|
|
157
|
+
content: "";
|
|
158
|
+
position: absolute;
|
|
159
|
+
inset: 0;
|
|
160
|
+
background: radial-gradient(circle at top left, rgba(22, 163, 74, 0.08), transparent 55%);
|
|
161
|
+
pointer-events: none;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.accent-bar {
|
|
165
|
+
position: absolute;
|
|
166
|
+
top: 0;
|
|
167
|
+
left: 0;
|
|
168
|
+
right: 0;
|
|
169
|
+
height: 3px;
|
|
170
|
+
background: linear-gradient(90deg, #16a34a, #22c55e);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.content {
|
|
174
|
+
position: relative;
|
|
175
|
+
display: flex;
|
|
176
|
+
flex-direction: column;
|
|
177
|
+
align-items: center;
|
|
178
|
+
text-align: center;
|
|
179
|
+
gap: 14px;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.icon-wrapper {
|
|
183
|
+
width: 56px;
|
|
184
|
+
height: 56px;
|
|
185
|
+
border-radius: 999px;
|
|
186
|
+
display: flex;
|
|
187
|
+
align-items: center;
|
|
188
|
+
justify-content: center;
|
|
189
|
+
background: var(--accent-soft);
|
|
190
|
+
color: var(--accent-color);
|
|
191
|
+
margin-bottom: 4px;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.icon {
|
|
195
|
+
font-size: 30px;
|
|
196
|
+
line-height: 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
h1 {
|
|
200
|
+
margin: 0;
|
|
201
|
+
font-size: 22px;
|
|
202
|
+
font-weight: 600;
|
|
203
|
+
letter-spacing: -0.01em;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.subtitle {
|
|
207
|
+
margin: 0;
|
|
208
|
+
font-size: 14px;
|
|
209
|
+
color: var(--muted-text-color);
|
|
210
|
+
line-height: 1.5;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.tag {
|
|
214
|
+
display: inline-flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
gap: 6px;
|
|
217
|
+
margin-top: 8px;
|
|
218
|
+
padding: 3px 9px;
|
|
219
|
+
border-radius: 999px;
|
|
220
|
+
background-color: rgba(15, 23, 42, 0.03);
|
|
221
|
+
color: var(--muted-text-color);
|
|
222
|
+
font-size: 11px;
|
|
223
|
+
text-transform: uppercase;
|
|
224
|
+
letter-spacing: 0.09em;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@media (prefers-color-scheme: dark) {
|
|
228
|
+
.tag {
|
|
229
|
+
background-color: rgba(15, 23, 42, 0.7);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.tag-dot {
|
|
234
|
+
width: 7px;
|
|
235
|
+
height: 7px;
|
|
236
|
+
border-radius: 999px;
|
|
237
|
+
background-color: var(--accent-color);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.footer-note {
|
|
241
|
+
margin-top: 20px;
|
|
242
|
+
font-size: 13px;
|
|
243
|
+
color: var(--muted-text-color);
|
|
244
|
+
text-align: center;
|
|
245
|
+
}
|
|
246
|
+
</style>
|
|
88
247
|
</head>
|
|
89
|
-
<body
|
|
90
|
-
<
|
|
91
|
-
|
|
248
|
+
<body>
|
|
249
|
+
<div class="container">
|
|
250
|
+
<div class="card" role="status" aria-live="polite">
|
|
251
|
+
<div class="accent-bar"></div>
|
|
252
|
+
<div class="content">
|
|
253
|
+
<div class="icon-wrapper">
|
|
254
|
+
<div class="icon">✓</div>
|
|
255
|
+
</div>
|
|
256
|
+
<h1>Login successful</h1>
|
|
257
|
+
<p class="subtitle">
|
|
258
|
+
You're now signed in to the Tanagram CLI.<br>
|
|
259
|
+
You can safely close this tab and return to your terminal.
|
|
260
|
+
</p>
|
|
261
|
+
<div class="tag">
|
|
262
|
+
<span class="tag-dot"></span>
|
|
263
|
+
<span>Authenticated from this device</span>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
<p class="footer-note">
|
|
268
|
+
If this window doesn't close automatically, you can just close it manually.
|
|
269
|
+
</p>
|
|
270
|
+
</div>
|
|
92
271
|
</body>
|
|
93
272
|
</html>
|
|
94
273
|
`)
|
package/commands/snapshot.go
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
package commands
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"encoding/json"
|
|
4
5
|
"fmt"
|
|
6
|
+
"os"
|
|
5
7
|
|
|
6
8
|
"github.com/tanagram/cli/snapshot"
|
|
7
9
|
"github.com/tanagram/cli/storage"
|
|
@@ -11,7 +13,7 @@ import (
|
|
|
11
13
|
// Extracted variables so that tests can override these
|
|
12
14
|
var (
|
|
13
15
|
findGitRoot = storage.FindGitRoot
|
|
14
|
-
|
|
16
|
+
createOptimized = snapshot.CreateOptimized
|
|
15
17
|
getParentProcess = utils.GetParentProcess
|
|
16
18
|
)
|
|
17
19
|
|
|
@@ -23,8 +25,28 @@ func Snapshot() error {
|
|
|
23
25
|
return err
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
var targetFiles []string
|
|
29
|
+
|
|
30
|
+
// Check if there is input on stdin (e.g. from Claude Code hook)
|
|
31
|
+
stat, err := os.Stdin.Stat()
|
|
32
|
+
if err == nil && (stat.Mode()&os.ModeCharDevice) == 0 && stat.Size() > 0 {
|
|
33
|
+
var input struct {
|
|
34
|
+
ToolInput struct {
|
|
35
|
+
FilePath string `json:"file_path"`
|
|
36
|
+
} `json:"tool_input"`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
decoder := json.NewDecoder(os.Stdin)
|
|
40
|
+
// Ignore errors decoding, just proceed with empty target if fails
|
|
41
|
+
if err := decoder.Decode(&input); err == nil {
|
|
42
|
+
if input.ToolInput.FilePath != "" {
|
|
43
|
+
targetFiles = []string{input.ToolInput.FilePath}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
// Create snapshot
|
|
27
|
-
if err :=
|
|
49
|
+
if err := createOptimized(gitRoot, targetFiles); err != nil {
|
|
28
50
|
return fmt.Errorf("failed to create snapshot: %w", err)
|
|
29
51
|
}
|
|
30
52
|
|
package/package.json
CHANGED
package/snapshot/snapshot.go
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
package snapshot
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"bytes"
|
|
4
5
|
"crypto/md5"
|
|
5
6
|
"encoding/json"
|
|
6
7
|
"fmt"
|
|
7
8
|
"io"
|
|
8
9
|
"os"
|
|
10
|
+
"os/exec"
|
|
9
11
|
"path/filepath"
|
|
10
12
|
"strings"
|
|
11
13
|
"time"
|
|
@@ -23,8 +25,9 @@ type FileState struct {
|
|
|
23
25
|
|
|
24
26
|
// Snapshot represents a snapshot of the working directory
|
|
25
27
|
type Snapshot struct {
|
|
26
|
-
Timestamp
|
|
27
|
-
|
|
28
|
+
Timestamp time.Time `json:"timestamp"`
|
|
29
|
+
HeadCommit string `json:"head_commit,omitempty"`
|
|
30
|
+
Files map[string]*FileState `json:"files"`
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
// SnapshotPath returns the path to the snapshot file
|
|
@@ -32,7 +35,36 @@ func SnapshotPath(gitRoot string) string {
|
|
|
32
35
|
return filepath.Join(gitRoot, ".tanagram", "snapshot.json")
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
// runGit executes a git command in the given directory
|
|
39
|
+
func runGit(dir string, args ...string) (string, error) {
|
|
40
|
+
cmd := exec.Command("git", args...)
|
|
41
|
+
cmd.Dir = dir
|
|
42
|
+
out, err := cmd.Output()
|
|
43
|
+
if err != nil {
|
|
44
|
+
return "", err
|
|
45
|
+
}
|
|
46
|
+
return string(bytes.TrimSpace(out)), nil
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// getHashAtCommit returns the MD5 hash of a file at a specific commit
|
|
50
|
+
func getHashAtCommit(gitRoot, commit, path string) (string, error) {
|
|
51
|
+
// git show commit:path
|
|
52
|
+
cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", commit, path))
|
|
53
|
+
cmd.Dir = gitRoot
|
|
54
|
+
|
|
55
|
+
// We need to hash the output
|
|
56
|
+
h := md5.New()
|
|
57
|
+
cmd.Stdout = h
|
|
58
|
+
|
|
59
|
+
if err := cmd.Run(); err != nil {
|
|
60
|
+
return "", err
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
|
64
|
+
}
|
|
65
|
+
|
|
35
66
|
// Create creates a new snapshot of all tracked and modified files
|
|
67
|
+
// This is kept for backward compatibility or fallback
|
|
36
68
|
func Create(gitRoot string) error {
|
|
37
69
|
snapshot := &Snapshot{
|
|
38
70
|
Timestamp: time.Now(),
|
|
@@ -84,6 +116,80 @@ func Create(gitRoot string) error {
|
|
|
84
116
|
return Save(gitRoot, snapshot)
|
|
85
117
|
}
|
|
86
118
|
|
|
119
|
+
// CreateOptimized creates a snapshot using git status/target files
|
|
120
|
+
func CreateOptimized(gitRoot string, targetFiles []string) error {
|
|
121
|
+
// Get current HEAD
|
|
122
|
+
headCommit, err := runGit(gitRoot, "rev-parse", "HEAD")
|
|
123
|
+
if err != nil {
|
|
124
|
+
// Fallback to full create if no git/head
|
|
125
|
+
return Create(gitRoot)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
snapshot := &Snapshot{
|
|
129
|
+
Timestamp: time.Now(),
|
|
130
|
+
HeadCommit: headCommit,
|
|
131
|
+
Files: make(map[string]*FileState),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Use a map to deduplicate files
|
|
135
|
+
filesToSnapshot := make(map[string]bool)
|
|
136
|
+
|
|
137
|
+
// 1. Add target files (if provided)
|
|
138
|
+
for _, f := range targetFiles {
|
|
139
|
+
filesToSnapshot[f] = true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2. Add all currently dirty files (modified/staged/untracked)
|
|
143
|
+
// This ensures that if the user has uncommitted changes, we record them
|
|
144
|
+
// so that we don't falsely attribute them to the agent later.
|
|
145
|
+
out, err := runGit(gitRoot, "status", "--porcelain")
|
|
146
|
+
if err == nil {
|
|
147
|
+
lines := strings.Split(out, "\n")
|
|
148
|
+
for _, line := range lines {
|
|
149
|
+
if len(line) < 4 {
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
// Format: XY PATH or XY PATH -> NEWPATH
|
|
153
|
+
content := line[3:]
|
|
154
|
+
if idx := strings.Index(content, " -> "); idx != -1 {
|
|
155
|
+
// Rename, take the new path
|
|
156
|
+
content = content[idx+4:]
|
|
157
|
+
}
|
|
158
|
+
path := strings.Trim(content, "\"")
|
|
159
|
+
filesToSnapshot[path] = true
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for relPath := range filesToSnapshot {
|
|
164
|
+
fullPath := filepath.Join(gitRoot, relPath)
|
|
165
|
+
|
|
166
|
+
// Skip if not exist (deleted)
|
|
167
|
+
info, err := os.Stat(fullPath)
|
|
168
|
+
if err != nil {
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Skip directories
|
|
173
|
+
if info.IsDir() {
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
hash, err := hashFile(fullPath)
|
|
178
|
+
if err != nil {
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
snapshot.Files[relPath] = &FileState{
|
|
183
|
+
Path: relPath,
|
|
184
|
+
Hash: hash,
|
|
185
|
+
Size: info.Size(),
|
|
186
|
+
ModifiedTime: info.ModTime(),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return Save(gitRoot, snapshot)
|
|
191
|
+
}
|
|
192
|
+
|
|
87
193
|
// Load loads an existing snapshot
|
|
88
194
|
func Load(gitRoot string) (*Snapshot, error) {
|
|
89
195
|
path := SnapshotPath(gitRoot)
|
|
@@ -146,6 +252,80 @@ type CompareResult struct {
|
|
|
146
252
|
|
|
147
253
|
// Compare compares the current working directory to a snapshot
|
|
148
254
|
func Compare(gitRoot string, snapshot *Snapshot) (*CompareResult, error) {
|
|
255
|
+
if snapshot.HeadCommit == "" {
|
|
256
|
+
return compareLegacy(gitRoot, snapshot)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
result := &CompareResult{
|
|
260
|
+
ModifiedFiles: []string{},
|
|
261
|
+
DeletedFiles: []string{},
|
|
262
|
+
NewFiles: []string{},
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
candidates := make(map[string]bool)
|
|
266
|
+
|
|
267
|
+
// 1. Files in snapshot
|
|
268
|
+
for f := range snapshot.Files {
|
|
269
|
+
candidates[f] = true
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 2. Files changed relative to HeadCommit
|
|
273
|
+
// git diff --name-only HeadCommit
|
|
274
|
+
out, err := runGit(gitRoot, "diff", "--name-only", snapshot.HeadCommit)
|
|
275
|
+
if err == nil {
|
|
276
|
+
for _, f := range strings.Split(out, "\n") {
|
|
277
|
+
if f != "" {
|
|
278
|
+
candidates[f] = true
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 3. Untracked files
|
|
284
|
+
// git ls-files --others --exclude-standard
|
|
285
|
+
out, err = runGit(gitRoot, "ls-files", "--others", "--exclude-standard")
|
|
286
|
+
if err == nil {
|
|
287
|
+
for _, f := range strings.Split(out, "\n") {
|
|
288
|
+
if f != "" {
|
|
289
|
+
candidates[f] = true
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for path := range candidates {
|
|
295
|
+
// Determine Current Hash
|
|
296
|
+
var currentHash string
|
|
297
|
+
fullPath := filepath.Join(gitRoot, path)
|
|
298
|
+
info, err := os.Stat(fullPath)
|
|
299
|
+
if err == nil && !info.IsDir() {
|
|
300
|
+
currentHash, _ = hashFile(fullPath)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Determine Snapshot Hash
|
|
304
|
+
var snapshotHash string
|
|
305
|
+
if state, ok := snapshot.Files[path]; ok {
|
|
306
|
+
snapshotHash = state.Hash
|
|
307
|
+
} else {
|
|
308
|
+
// Not in snapshot, assume equal to HeadCommit
|
|
309
|
+
// If file didn't exist in HeadCommit, getHashAtCommit returns err/empty
|
|
310
|
+
snapshotHash, _ = getHashAtCommit(gitRoot, snapshot.HeadCommit, path)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if currentHash == "" && snapshotHash == "" {
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
if currentHash == "" {
|
|
317
|
+
result.DeletedFiles = append(result.DeletedFiles, path)
|
|
318
|
+
} else if snapshotHash == "" {
|
|
319
|
+
result.NewFiles = append(result.NewFiles, path)
|
|
320
|
+
} else if currentHash != snapshotHash {
|
|
321
|
+
result.ModifiedFiles = append(result.ModifiedFiles, path)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return result, nil
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
func compareLegacy(gitRoot string, snapshot *Snapshot) (*CompareResult, error) {
|
|
149
329
|
result := &CompareResult{
|
|
150
330
|
ModifiedFiles: []string{},
|
|
151
331
|
DeletedFiles: []string{},
|