atris 2.5.4 → 2.6.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/atris/policies/atris-design.md +200 -15
- package/atris/skills/design/SKILL.md +32 -7
- package/bin/atris.js +19 -8
- package/commands/auth.js +317 -67
- package/package.json +1 -1
- package/utils/auth.js +127 -0
|
@@ -19,33 +19,93 @@ this is the "distribution center" — statistically average, aesthetically dead.
|
|
|
19
19
|
|
|
20
20
|
## typography
|
|
21
21
|
|
|
22
|
-
**avoid:** inter, roboto, open sans, lato, system defaults
|
|
22
|
+
**avoid:** inter, roboto, open sans, lato, arial, montserrat, system defaults. also avoid monospace as lazy shorthand for "technical/developer" vibes.
|
|
23
23
|
|
|
24
24
|
**try instead:**
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
25
|
+
- instead of inter → instrument sans, plus jakarta sans, outfit
|
|
26
|
+
- instead of roboto → onest, figtree, urbanist
|
|
27
|
+
- instead of open sans → source sans 3, nunito sans, dm sans
|
|
28
|
+
- editorial/premium → fraunces, newsreader, lora, playfair display, crimson pro
|
|
29
|
+
- dev tools → jetbrains mono, fira code
|
|
30
|
+
- clean technical → ibm plex, source sans
|
|
28
31
|
|
|
29
32
|
**the move:** pick ONE distinctive font. use weight extremes (200 vs 800, not 400 vs 500). size jumps should be dramatic (3x), not timid (1.2x).
|
|
30
33
|
|
|
34
|
+
**type scale:** use a modular scale with fluid sizing. 5 sizes covers most needs:
|
|
35
|
+
|
|
36
|
+
| role | size | use |
|
|
37
|
+
|------|------|-----|
|
|
38
|
+
| xs | 0.75rem | captions, legal |
|
|
39
|
+
| sm | 0.875rem | secondary UI, metadata |
|
|
40
|
+
| base | 1rem | body text |
|
|
41
|
+
| lg | 1.25-1.5rem | subheadings, lead |
|
|
42
|
+
| xl+ | 2-4rem | headlines, hero |
|
|
43
|
+
|
|
44
|
+
use `clamp()` for fluid sizing: `clamp(1rem, 0.5rem + 2vw, 2rem)`. use `ch` units for measure: `max-width: 65ch` for readable body text.
|
|
45
|
+
|
|
46
|
+
**vertical rhythm:** your `line-height` should be the base unit for all vertical spacing. if body is `line-height: 1.5` on `16px` (= 24px), spacing values should be multiples of 24px.
|
|
47
|
+
|
|
48
|
+
**font loading:** prevent layout shift with proper loading:
|
|
49
|
+
```css
|
|
50
|
+
@font-face {
|
|
51
|
+
font-family: 'CustomFont';
|
|
52
|
+
src: url('font.woff2') format('woff2');
|
|
53
|
+
font-display: swap;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
match fallback metrics with `size-adjust`, `ascent-override`, `descent-override` to minimize FOUT reflow.
|
|
57
|
+
|
|
31
58
|
---
|
|
32
59
|
|
|
33
60
|
## color
|
|
34
61
|
|
|
35
|
-
**avoid:** purple/violet on white, generic startup palettes, safe grays
|
|
62
|
+
**avoid:** purple/violet on white, generic startup palettes, safe grays, pure black (#000), pure white (#fff), the AI color palette (cyan-on-dark, purple-to-blue gradients, neon accents on dark backgrounds), gradient text for "impact"
|
|
36
63
|
|
|
37
64
|
**the move:** commit to a palette and stick to it. use css variables. one dominant color with a sharp accent beats five evenly-distributed colors.
|
|
38
65
|
|
|
39
|
-
|
|
66
|
+
**use OKLCH:** modern, perceptually uniform color space. equal steps in lightness actually look equal.
|
|
67
|
+
```css
|
|
68
|
+
:root {
|
|
69
|
+
--brand: oklch(65% 0.2 250);
|
|
70
|
+
--brand-light: oklch(90% 0.05 250);
|
|
71
|
+
--brand-dark: oklch(30% 0.15 250);
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
use `color-mix()` for variants: `color-mix(in oklch, var(--brand) 80%, white)`. use `light-dark()` for theme-aware values.
|
|
76
|
+
|
|
77
|
+
**tint your neutrals:** never use pure gray. always tint toward your brand hue — even 0.01 chroma in OKLCH creates subconscious cohesion:
|
|
78
|
+
```css
|
|
79
|
+
--gray-100: oklch(95% 0.01 250); /* not #f5f5f5 */
|
|
80
|
+
--gray-900: oklch(15% 0.01 250); /* not #1a1a1a */
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**gray on color:** never put gray text on colored backgrounds. it looks washed out. use a darker shade of the background color or transparency instead.
|
|
84
|
+
|
|
85
|
+
dark backgrounds are easier to make look good. steal from places you like — linear.app, vercel.com, raycast.com, arc browser. but don't default to dark mode with glowing accents — it looks "cool" without requiring actual design decisions.
|
|
40
86
|
|
|
41
87
|
---
|
|
42
88
|
|
|
43
89
|
## layout
|
|
44
90
|
|
|
45
|
-
**avoid:** the template look — hero section, 3 feature cards, testimonial carousel, big footer. every ai does this.
|
|
91
|
+
**avoid:** the template look — hero section, 3 feature cards, testimonial carousel, big footer. every ai does this. also avoid:
|
|
92
|
+
- wrapping everything in cards — not everything needs a container
|
|
93
|
+
- nesting cards inside cards — visual noise, flatten the hierarchy
|
|
94
|
+
- identical card grids — same-sized cards with icon + heading + text, repeated endlessly
|
|
95
|
+
- the hero metric layout — big number, small label, supporting stats, gradient accent
|
|
96
|
+
- centering everything — left-aligned text with asymmetric layouts feels more designed
|
|
97
|
+
- same spacing everywhere — without rhythm, layouts feel monotonous
|
|
46
98
|
|
|
47
99
|
**the move:** break the grid sometimes. asymmetry is interesting. whitespace is a feature, not wasted space. don't cram everything into 16px/24px spacing — use dramatic gaps.
|
|
48
100
|
|
|
101
|
+
**fluid spacing:** use `clamp()` for spacing that breathes on larger screens:
|
|
102
|
+
```css
|
|
103
|
+
padding: clamp(1rem, 3vw, 4rem);
|
|
104
|
+
gap: clamp(1.5rem, 4vw, 6rem);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**container queries:** use `@container` for component-level responsiveness instead of only viewport breakpoints. components should adapt to their container, not just the screen.
|
|
108
|
+
|
|
49
109
|
---
|
|
50
110
|
|
|
51
111
|
## motion
|
|
@@ -58,10 +118,52 @@ dark backgrounds are easier to make look good. steal from places you like — li
|
|
|
58
118
|
- buttons that follow the cursor (harder to click, not clever)
|
|
59
119
|
- FAQ/content that breaks if you scroll past before the fade-in finishes
|
|
60
120
|
- animations that swap styles endlessly without purpose (rotating shapes, morphing buttons)
|
|
121
|
+
- bounce or elastic easing — they feel dated and tacky. real objects decelerate smoothly.
|
|
122
|
+
|
|
123
|
+
**the move:** one well-timed animation beats ten scattered ones. page load with staggered reveals (50-100ms delays) creates more impact than hover effects on every button.
|
|
124
|
+
|
|
125
|
+
**easing:** use exponential easing for natural deceleration:
|
|
126
|
+
```css
|
|
127
|
+
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
|
|
128
|
+
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
|
129
|
+
```
|
|
130
|
+
never linear, never bounce, never elastic. 150-300ms duration for most transitions.
|
|
131
|
+
|
|
132
|
+
**only animate transform and opacity.** never animate width, height, padding, margin — they trigger layout recalculation and cause jank. for height animations, use `grid-template-rows` transitions.
|
|
133
|
+
|
|
134
|
+
**reduced motion:** always respect `prefers-reduced-motion`. provide a beautiful static alternative:
|
|
135
|
+
```css
|
|
136
|
+
@media (prefers-reduced-motion: reduce) {
|
|
137
|
+
*, *::before, *::after {
|
|
138
|
+
animation-duration: 0.01ms !important;
|
|
139
|
+
transition-duration: 0.01ms !important;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
61
143
|
|
|
62
|
-
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## interaction
|
|
147
|
+
|
|
148
|
+
**progressive disclosure:** start simple, reveal sophistication through interaction. basic options first, advanced behind expandable sections. hover states that reveal secondary actions.
|
|
149
|
+
|
|
150
|
+
**optimistic UI:** update immediately, sync later. makes everything feel fast.
|
|
63
151
|
|
|
64
|
-
|
|
152
|
+
**empty states:** design empty states that teach the interface, not just say "nothing here." show users what to do next.
|
|
153
|
+
|
|
154
|
+
**button hierarchy:** don't make every button primary. use ghost buttons, text links, secondary styles. hierarchy matters.
|
|
155
|
+
|
|
156
|
+
**every interactive element needs ALL states:**
|
|
157
|
+
- default — resting
|
|
158
|
+
- hover — subtle feedback (brighten, scale 1.02-1.05)
|
|
159
|
+
- focus — visible keyboard indicator (never remove without replacement)
|
|
160
|
+
- active — click/tap feedback
|
|
161
|
+
- disabled — clearly non-interactive
|
|
162
|
+
- loading — async action feedback
|
|
163
|
+
- error — validation state
|
|
164
|
+
- success — confirmation
|
|
165
|
+
|
|
166
|
+
missing states create confusion and broken experiences.
|
|
65
167
|
|
|
66
168
|
---
|
|
67
169
|
|
|
@@ -79,6 +181,25 @@ test every hover on mobile. if something only works on hover, it's broken for ha
|
|
|
79
181
|
|
|
80
182
|
---
|
|
81
183
|
|
|
184
|
+
## responsive
|
|
185
|
+
|
|
186
|
+
**avoid:** hiding critical functionality on mobile — adapt the interface, don't amputate it. also avoid:
|
|
187
|
+
- fixed widths that break on small screens
|
|
188
|
+
- touch targets smaller than 44x44px
|
|
189
|
+
- text smaller than 14px on mobile
|
|
190
|
+
- horizontal scroll from content overflow
|
|
191
|
+
|
|
192
|
+
**the move:** mobile-first, then enhance for larger screens. use fluid layouts with `clamp()` and container queries. adapt the interface for different contexts — don't just shrink it.
|
|
193
|
+
|
|
194
|
+
```css
|
|
195
|
+
/* container queries > media queries for components */
|
|
196
|
+
@container (min-width: 400px) {
|
|
197
|
+
.card { flex-direction: row; }
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
82
203
|
## scroll behavior
|
|
83
204
|
|
|
84
205
|
**avoid:** scrolljacking — never override native browser scroll with custom scroll logic. it feels like "moving through molasses" and users hate it.
|
|
@@ -95,21 +216,43 @@ use the "peeking" technique: let a few pixels of the next section peek above the
|
|
|
95
216
|
|
|
96
217
|
**the move:** add depth. layered gradients, subtle patterns, mesh effects. backgrounds set mood — flat backgrounds say "I didn't think about this."
|
|
97
218
|
|
|
219
|
+
but don't use glassmorphism everywhere — blur effects, glass cards, glow borders used decoratively rather than purposefully. it's AI slop.
|
|
220
|
+
|
|
98
221
|
---
|
|
99
222
|
|
|
100
|
-
##
|
|
223
|
+
## accessibility
|
|
101
224
|
|
|
102
|
-
|
|
225
|
+
this isn't optional — it's part of good design.
|
|
103
226
|
|
|
104
|
-
|
|
227
|
+
- **contrast:** 4.5:1 minimum for text, 3:1 for UI components (WCAG AA)
|
|
228
|
+
- **focus indicators:** visible, high-contrast focus rings on all interactive elements. never `outline: none` without a replacement.
|
|
229
|
+
- **semantic HTML:** use proper heading hierarchy, landmarks, buttons (not divs), labels on inputs
|
|
230
|
+
- **color independence:** never use color as the only indicator. add icons, labels, or patterns alongside.
|
|
231
|
+
- **keyboard nav:** logical tab order, no keyboard traps, all functionality accessible without a mouse
|
|
105
232
|
|
|
106
233
|
---
|
|
107
234
|
|
|
108
|
-
##
|
|
235
|
+
## visual details
|
|
109
236
|
|
|
110
|
-
|
|
237
|
+
**avoid:**
|
|
238
|
+
- glassmorphism everywhere (blur effects, glass cards, glow borders)
|
|
239
|
+
- rounded elements with thick colored border on one side — lazy accent
|
|
240
|
+
- sparklines as decoration — tiny charts that convey nothing meaningful
|
|
241
|
+
- rounded rectangles with generic drop shadows — safe, forgettable
|
|
242
|
+
- large icons with rounded corners above every heading — templated look
|
|
243
|
+
- modals unless there's truly no better alternative — modals are lazy
|
|
244
|
+
- non-system emojis used as decoration (lazy AI tell)
|
|
111
245
|
|
|
112
|
-
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## ux writing
|
|
249
|
+
|
|
250
|
+
**make every word earn its place.**
|
|
251
|
+
- don't repeat information users can already see
|
|
252
|
+
- don't repeat the same information — redundant headers, intros that restate the heading
|
|
253
|
+
- labels and buttons should be unambiguous
|
|
254
|
+
- error copy should help users fix the problem, not blame them
|
|
255
|
+
- empty states should guide toward action
|
|
113
256
|
|
|
114
257
|
---
|
|
115
258
|
|
|
@@ -144,6 +287,22 @@ if a stranger can't answer all four in 5 seconds of looking at your hero, rewrit
|
|
|
144
287
|
|
|
145
288
|
---
|
|
146
289
|
|
|
290
|
+
## context matters
|
|
291
|
+
|
|
292
|
+
don't impose an aesthetic — match the project. a fintech dashboard shouldn't look like a gaming site. read the room.
|
|
293
|
+
|
|
294
|
+
if the project already has a design system, use it. don't fight it to show off.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## the convergence trap
|
|
299
|
+
|
|
300
|
+
even with this guidance you'll find new defaults. space grotesk becomes the new inter. dark mode with amber accents becomes the new purple gradient.
|
|
301
|
+
|
|
302
|
+
vary your choices. alternate themes. try different directions between projects.
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
147
306
|
## before shipping
|
|
148
307
|
|
|
149
308
|
- can you name the aesthetic in 2-3 words?
|
|
@@ -154,12 +313,38 @@ if a stranger can't answer all four in 5 seconds of looking at your hero, rewrit
|
|
|
154
313
|
- does scrolling feel native?
|
|
155
314
|
- does the hero pass the H1 test (what/who/why/CTA)?
|
|
156
315
|
- are all screenshots/assets crisp?
|
|
316
|
+
- do all interactive elements have all states (hover/focus/active/disabled/loading/error)?
|
|
317
|
+
- does it meet WCAG AA contrast (4.5:1 text, 3:1 UI)?
|
|
318
|
+
- does it work on mobile (touch targets, no horizontal scroll, readable text)?
|
|
319
|
+
- does it respect `prefers-reduced-motion`?
|
|
157
320
|
- would a designer immediately clock this as ai-generated?
|
|
158
321
|
|
|
159
322
|
if the last answer is yes, you're not done.
|
|
160
323
|
|
|
161
324
|
---
|
|
162
325
|
|
|
326
|
+
## the ai slop test
|
|
327
|
+
|
|
328
|
+
> "if you showed this interface to someone and said 'AI made this,' would they believe you immediately? if yes, that's the problem."
|
|
329
|
+
|
|
330
|
+
the fingerprints of AI-generated work:
|
|
331
|
+
- inter/roboto/system fonts
|
|
332
|
+
- purple-to-blue gradients
|
|
333
|
+
- cyan-on-dark color schemes
|
|
334
|
+
- glassmorphism everywhere
|
|
335
|
+
- gradient text on headings/metrics
|
|
336
|
+
- hero metric layout (big number + small label)
|
|
337
|
+
- identical card grids
|
|
338
|
+
- bounce/elastic easing
|
|
339
|
+
- dark mode with neon accents
|
|
340
|
+
- sparklines as decoration
|
|
341
|
+
- rounded rectangles with drop shadows
|
|
342
|
+
- large icons with rounded corners above headings
|
|
343
|
+
|
|
344
|
+
a distinctive interface should make someone ask "how was this made?" not "which AI made this?"
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
163
348
|
## references
|
|
164
349
|
|
|
165
350
|
look at these for inspiration, not to copy:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: design
|
|
3
3
|
description: Frontend aesthetics policy. Use when building UI, components, landing pages, dashboards, or any frontend work. Prevents generic ai-generated look.
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
allowed-tools: Read, Write, Edit, Bash, Glob
|
|
6
6
|
tags:
|
|
7
7
|
- design
|
|
@@ -21,26 +21,47 @@ This skill uses the Atris workflow:
|
|
|
21
21
|
|
|
22
22
|
## Quick Reference
|
|
23
23
|
|
|
24
|
-
**Typography:** avoid inter/roboto/system fonts. pick one distinctive font, use weight extremes (200 vs 800).
|
|
24
|
+
**Typography:** avoid inter/roboto/arial/system fonts. pick one distinctive font, use weight extremes (200 vs 800). size jumps should be dramatic (3x). use `clamp()` for fluid sizing. use `ch` units for measure (`max-width: 65ch`).
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Font alternatives: instead of Inter → Instrument Sans, Plus Jakarta Sans, Outfit. Instead of Roboto → Onest, Figtree, Urbanist. Editorial → Fraunces, Newsreader, Lora.
|
|
27
27
|
|
|
28
|
-
**
|
|
28
|
+
**Color:** commit to a palette. use OKLCH for perceptually uniform colors. tint your neutrals toward your brand hue (never pure gray). never put gray text on colored backgrounds. never use pure black (#000) or pure white (#fff). avoid the AI palette: cyan-on-dark, purple-to-blue gradients, neon accents on dark.
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
```css
|
|
31
|
+
--brand: oklch(65% 0.2 250);
|
|
32
|
+
--gray-100: oklch(95% 0.01 250); /* tinted, not pure gray */
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Layout:** break the hero + 3 cards + footer template. no card-in-card nesting. no identical card grids. asymmetry is interesting. dramatic whitespace. use container queries for component-level responsiveness. fluid spacing with `clamp()`.
|
|
36
|
+
|
|
37
|
+
**Motion:** one well-timed animation beats ten scattered ones. use exponential easing (`cubic-bezier(0.25, 1, 0.5, 1)`), never bounce/elastic. 150-300ms duration. only animate transform and opacity. always respect `prefers-reduced-motion`. no cursor-following lines, no meteor effects, no buttons that chase the cursor.
|
|
31
38
|
|
|
32
|
-
**
|
|
39
|
+
**Interaction:** progressive disclosure — start simple, reveal complexity. optimistic UI — update immediately, sync later. every interactive element needs ALL states: default, hover, focus, active, disabled, loading, error, success. don't make every button primary.
|
|
40
|
+
|
|
41
|
+
**Hover:** make elements feel inviting on hover (brighten, subtle scale 1.02-1.05). never fade out, shift, or hide content behind hover. hover doesn't exist on mobile.
|
|
33
42
|
|
|
34
43
|
**Scroll:** never override native scroll. use "peeking" (show a few px of next section) instead of full-screen hero + scroll arrow.
|
|
35
44
|
|
|
45
|
+
**Responsive:** mobile-first. touch targets 44x44px minimum. no text under 14px on mobile. no horizontal scroll. container queries > media queries for components. adapt, don't amputate.
|
|
46
|
+
|
|
47
|
+
**Accessibility:** 4.5:1 contrast for text, 3:1 for UI (WCAG AA). visible focus indicators always. semantic HTML. never use color alone as an indicator. keyboard nav with logical tab order.
|
|
48
|
+
|
|
36
49
|
**Hero (H1 test):** must answer in 5 seconds — what is it, who is it for, why care, what's the CTA.
|
|
37
50
|
|
|
38
51
|
**Assets:** high-res screenshots only. no fake dashboards with primary colors. no decorative non-system emojis.
|
|
39
52
|
|
|
40
|
-
**Backgrounds:** add depth. gradients, patterns, mesh effects. flat = boring.
|
|
53
|
+
**Backgrounds:** add depth. gradients, patterns, mesh effects. flat = boring. but no glassmorphism everywhere — that's AI slop.
|
|
41
54
|
|
|
42
55
|
**Hierarchy:** 2-3 text levels max. don't mix 5 competing styles.
|
|
43
56
|
|
|
57
|
+
**Visual anti-patterns:** no glassmorphism, no gradient text, no sparklines as decoration, no rounded-rect-with-colored-border, no large icons with rounded corners above headings, no hero metric layout (big number + small label), no modals unless truly necessary.
|
|
58
|
+
|
|
59
|
+
## The AI Slop Test
|
|
60
|
+
|
|
61
|
+
> "if you showed this to someone and said 'AI made this,' would they believe you immediately? if yes, that's the problem."
|
|
62
|
+
|
|
63
|
+
Fingerprints: inter/roboto, purple-to-blue gradients, cyan-on-dark, glassmorphism, gradient text, hero metrics, identical card grids, bounce easing, dark mode with neon, sparklines as decoration, rounded rectangles with drop shadows.
|
|
64
|
+
|
|
44
65
|
## Before Shipping Checklist
|
|
45
66
|
|
|
46
67
|
Run through `atris/policies/atris-design.md` "before shipping" section:
|
|
@@ -52,6 +73,10 @@ Run through `atris/policies/atris-design.md` "before shipping" section:
|
|
|
52
73
|
- scrolling feels native?
|
|
53
74
|
- hero passes H1 test (what/who/why/CTA)?
|
|
54
75
|
- all assets crisp?
|
|
76
|
+
- all interactive elements have all states (hover/focus/active/disabled/loading/error)?
|
|
77
|
+
- WCAG AA contrast (4.5:1 text, 3:1 UI)?
|
|
78
|
+
- works on mobile (44px touch targets, no horizontal scroll, readable text)?
|
|
79
|
+
- respects `prefers-reduced-motion`?
|
|
55
80
|
- would a designer clock this as ai-generated?
|
|
56
81
|
|
|
57
82
|
## Atris Commands
|
package/bin/atris.js
CHANGED
|
@@ -251,11 +251,12 @@ function showHelp() {
|
|
|
251
251
|
console.log(' console - Start/attach always-on coding console (tmux daemon)');
|
|
252
252
|
console.log(' agent - Select which Atris agent to use');
|
|
253
253
|
console.log(' chat - Chat with the selected Atris agent');
|
|
254
|
-
console.log(' login -
|
|
255
|
-
console.log(' logout -
|
|
256
|
-
console.log(' whoami - Show
|
|
257
|
-
console.log(' switch - Switch account (atris switch <name>)');
|
|
258
|
-
console.log('
|
|
254
|
+
console.log(' login - Sign in or add another account');
|
|
255
|
+
console.log(' logout - Sign out of current account');
|
|
256
|
+
console.log(' whoami - Show active account');
|
|
257
|
+
console.log(' switch - Switch account globally (atris switch <name>)');
|
|
258
|
+
console.log(' use - Set account for this terminal only (atris use <name>)');
|
|
259
|
+
console.log(' accounts - Manage accounts (list, add, remove)');
|
|
259
260
|
console.log('');
|
|
260
261
|
console.log('Integrations:');
|
|
261
262
|
console.log(' gmail - Email commands (inbox, read)');
|
|
@@ -384,8 +385,8 @@ const { planAtris: planCmd, doAtris: doCmd, reviewAtris: reviewCmd } = require('
|
|
|
384
385
|
|
|
385
386
|
// Check if this is a known command or natural language input
|
|
386
387
|
const knownCommands = ['init', 'log', 'status', 'analytics', 'visualize', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review',
|
|
387
|
-
'activate', 'agent', 'chat', 'console', 'login', 'logout', 'whoami', 'switch', 'accounts', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
|
|
388
|
-
'clean', 'verify', 'search', 'skill', 'member', 'plugin', 'experiments', 'pull', 'sync',
|
|
388
|
+
'activate', 'agent', 'chat', 'console', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
|
|
389
|
+
'clean', 'verify', 'search', 'skill', 'member', 'plugin', 'experiments', 'pull', 'push', 'business', 'sync',
|
|
389
390
|
'gmail', 'calendar', 'twitter', 'slack', 'integrations'];
|
|
390
391
|
|
|
391
392
|
// Check if command is an atris.md spec file - triggers welcome visualization
|
|
@@ -725,8 +726,18 @@ if (command === 'init') {
|
|
|
725
726
|
require('../commands/auth').whoamiAtris();
|
|
726
727
|
} else if (command === 'switch') {
|
|
727
728
|
require('../commands/auth').switchAccount();
|
|
729
|
+
} else if (command === 'use') {
|
|
730
|
+
require('../commands/auth').useAccount();
|
|
728
731
|
} else if (command === 'accounts') {
|
|
729
|
-
require('../commands/auth').
|
|
732
|
+
require('../commands/auth').accountsCmd();
|
|
733
|
+
} else if (command === '_resolve') {
|
|
734
|
+
// Hidden: resolve a profile name query → print exact profile name
|
|
735
|
+
require('../commands/auth').resolveProfile();
|
|
736
|
+
} else if (command === '_profile-email') {
|
|
737
|
+
// Hidden: print email for a profile name
|
|
738
|
+
require('../commands/auth').profileEmail();
|
|
739
|
+
} else if (command === 'shell-init') {
|
|
740
|
+
require('../commands/auth').shellInit();
|
|
730
741
|
} else if (command === 'visualize') {
|
|
731
742
|
console.log('ℹ️ "atris visualize" is a legacy helper. Visualization is now built into "atris plan".');
|
|
732
743
|
console.log(' Prefer: atris plan');
|
package/commands/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { loadCredentials, saveCredentials, deleteCredentials, getCredentialsPath, openBrowser, promptUser, displayAccountSummary, loadProfile, listProfiles, profileNameFromEmail } = require('../utils/auth');
|
|
1
|
+
const { loadCredentials, saveCredentials, deleteCredentials, getCredentialsPath, openBrowser, promptUser, displayAccountSummary, loadProfile, listProfiles, profileNameFromEmail, deleteProfile, getTerminalSessionId, setSessionProfile, getSessionProfile, clearSessionProfile, cleanStaleSessions } = require('../utils/auth');
|
|
2
2
|
const { getAppBaseUrl, apiRequestJson } = require('../utils/api');
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
@@ -11,8 +11,6 @@ async function loginAtris(options = {}) {
|
|
|
11
11
|
const directToken = tokenIndex !== -1 ? args[tokenIndex + 1] : options.token;
|
|
12
12
|
|
|
13
13
|
try {
|
|
14
|
-
console.log('🔐 Login to AtrisOS\n');
|
|
15
|
-
|
|
16
14
|
const existing = loadCredentials();
|
|
17
15
|
|
|
18
16
|
// Direct token mode (non-interactive)
|
|
@@ -30,13 +28,24 @@ async function loginAtris(options = {}) {
|
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
if (existing && !forceFlag) {
|
|
33
|
-
const label = existing.email || existing.user_id || 'unknown
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
console.log(
|
|
31
|
+
const label = existing.email || existing.user_id || 'unknown';
|
|
32
|
+
const profiles = listProfiles();
|
|
33
|
+
console.log(`Currently signed in as: ${label}`);
|
|
34
|
+
if (profiles.length > 1) {
|
|
35
|
+
console.log(`${profiles.length} accounts saved. Use "atris switch" to change.\n`);
|
|
36
|
+
}
|
|
37
|
+
console.log(' 1. Add another account');
|
|
38
|
+
console.log(' 2. Re-login to current account');
|
|
39
|
+
console.log(' 3. Cancel\n');
|
|
40
|
+
|
|
41
|
+
const choice = await promptUser('Choice (1-3): ');
|
|
42
|
+
if (choice === '3' || (!choice)) {
|
|
43
|
+
console.log('Cancelled.');
|
|
38
44
|
process.exit(0);
|
|
39
45
|
}
|
|
46
|
+
// Both 1 and 2 proceed to OAuth — the difference is just the prompt
|
|
47
|
+
} else if (!existing) {
|
|
48
|
+
console.log('Welcome to Atris! Let\'s get you signed in.\n');
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
console.log('Choose login method:');
|
|
@@ -44,21 +53,20 @@ async function loginAtris(options = {}) {
|
|
|
44
53
|
console.log(' 2. Paste existing API token');
|
|
45
54
|
console.log(' 3. Cancel');
|
|
46
55
|
|
|
47
|
-
const
|
|
56
|
+
const methodChoice = await promptUser('\nEnter choice (1-3): ');
|
|
48
57
|
|
|
49
|
-
if (
|
|
58
|
+
if (methodChoice === '1') {
|
|
50
59
|
const loginUrl = `${getAppBaseUrl()}/auth/cli`;
|
|
51
|
-
console.log('\
|
|
52
|
-
console.log('If it
|
|
53
|
-
console.log(loginUrl);
|
|
54
|
-
console.log('
|
|
55
|
-
console.log('Codes expire after five minutes.\n');
|
|
60
|
+
console.log('\nOpening browser…');
|
|
61
|
+
console.log('If it doesn\'t open, visit:');
|
|
62
|
+
console.log(` ${loginUrl}\n`);
|
|
63
|
+
console.log('After signing in, paste the CLI code shown in the browser.\n');
|
|
56
64
|
|
|
57
65
|
openBrowser(loginUrl);
|
|
58
66
|
|
|
59
|
-
const code = await promptUser('
|
|
67
|
+
const code = await promptUser('CLI code: ');
|
|
60
68
|
if (!code) {
|
|
61
|
-
console.error('
|
|
69
|
+
console.error('Error: Code is required.');
|
|
62
70
|
process.exit(1);
|
|
63
71
|
}
|
|
64
72
|
|
|
@@ -68,7 +76,7 @@ async function loginAtris(options = {}) {
|
|
|
68
76
|
});
|
|
69
77
|
|
|
70
78
|
if (!exchange.ok || !exchange.data) {
|
|
71
|
-
console.error(
|
|
79
|
+
console.error(`Error: ${exchange.error || 'Invalid or expired code'}`);
|
|
72
80
|
process.exit(1);
|
|
73
81
|
}
|
|
74
82
|
|
|
@@ -77,49 +85,47 @@ async function loginAtris(options = {}) {
|
|
|
77
85
|
const refreshToken = payload.refresh_token;
|
|
78
86
|
|
|
79
87
|
if (!token || !refreshToken) {
|
|
80
|
-
console.error('
|
|
88
|
+
console.error('Error: Backend did not return tokens. Try again.');
|
|
81
89
|
process.exit(1);
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
const email = payload.email ||
|
|
85
|
-
const userId = payload.user_id ||
|
|
92
|
+
const email = payload.email || null;
|
|
93
|
+
const userId = payload.user_id || null;
|
|
86
94
|
const provider = payload.provider || 'atris';
|
|
87
95
|
|
|
88
96
|
saveCredentials(token, refreshToken, email, userId, provider);
|
|
89
|
-
|
|
97
|
+
const name = profileNameFromEmail(email);
|
|
98
|
+
console.log(`\n✓ Signed in as ${email || 'unknown'}${name ? ` (profile: ${name})` : ''}`);
|
|
90
99
|
await displayAccountSummary(apiRequestJson);
|
|
91
|
-
console.log('\nYou can now use cloud features with atris commands.');
|
|
92
100
|
process.exit(0);
|
|
93
|
-
} else if (
|
|
94
|
-
console.log('\
|
|
95
|
-
console.log('Get your token from: https://atris.ai/auth/cli\n');
|
|
101
|
+
} else if (methodChoice === '2') {
|
|
102
|
+
console.log('\nGet your token from: https://atris.ai/auth/cli\n');
|
|
96
103
|
|
|
97
|
-
const tokenInput = await promptUser('
|
|
104
|
+
const tokenInput = await promptUser('API token: ');
|
|
98
105
|
|
|
99
106
|
if (!tokenInput) {
|
|
100
|
-
console.error('
|
|
107
|
+
console.error('Error: Token is required.');
|
|
101
108
|
process.exit(1);
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
const trimmed = tokenInput.trim();
|
|
105
112
|
saveCredentials(trimmed, null, existing?.email || null, existing?.user_id || null, existing?.provider || 'manual');
|
|
106
|
-
console.log('\
|
|
113
|
+
console.log('\nValidating…\n');
|
|
107
114
|
|
|
108
115
|
const summary = await displayAccountSummary(apiRequestJson);
|
|
109
116
|
if (summary.error) {
|
|
110
|
-
console.log('\n⚠️ Token saved, but validation failed.
|
|
117
|
+
console.log('\n⚠️ Token saved, but validation failed.');
|
|
111
118
|
} else {
|
|
112
|
-
console.log('\n✓ Token validated
|
|
119
|
+
console.log('\n✓ Token validated.');
|
|
113
120
|
}
|
|
114
121
|
|
|
115
|
-
console.log('\nYou can now use cloud features with atris commands.');
|
|
116
122
|
process.exit(0);
|
|
117
123
|
} else {
|
|
118
|
-
console.log('
|
|
124
|
+
console.log('Cancelled.');
|
|
119
125
|
process.exit(0);
|
|
120
126
|
}
|
|
121
127
|
} catch (error) {
|
|
122
|
-
console.error(`\
|
|
128
|
+
console.error(`\nLogin failed: ${error.message || error}`);
|
|
123
129
|
process.exit(1);
|
|
124
130
|
}
|
|
125
131
|
}
|
|
@@ -128,39 +134,52 @@ function logoutAtris() {
|
|
|
128
134
|
const credentials = loadCredentials();
|
|
129
135
|
|
|
130
136
|
if (!credentials) {
|
|
131
|
-
console.log('Not
|
|
137
|
+
console.log('Not signed in.');
|
|
132
138
|
process.exit(0);
|
|
133
139
|
}
|
|
134
140
|
|
|
141
|
+
const profiles = listProfiles();
|
|
142
|
+
const currentName = profileNameFromEmail(credentials?.email);
|
|
143
|
+
|
|
135
144
|
deleteCredentials();
|
|
136
|
-
console.log(
|
|
137
|
-
|
|
145
|
+
console.log(`✓ Signed out from ${credentials.email || 'current account'}`);
|
|
146
|
+
|
|
147
|
+
// Remind about other profiles
|
|
148
|
+
const remaining = profiles.filter(p => p !== currentName);
|
|
149
|
+
if (remaining.length > 0) {
|
|
150
|
+
console.log(`\n${remaining.length} other account${remaining.length > 1 ? 's' : ''} saved.`);
|
|
151
|
+
console.log(`Switch to one: atris switch ${remaining[0]}`);
|
|
152
|
+
console.log('Or remove all: atris accounts remove --all');
|
|
153
|
+
}
|
|
138
154
|
}
|
|
139
155
|
|
|
140
156
|
async function whoamiAtris() {
|
|
141
157
|
const { apiRequestJson } = require('../utils/api');
|
|
142
|
-
|
|
158
|
+
|
|
143
159
|
try {
|
|
144
160
|
const summary = await displayAccountSummary(apiRequestJson);
|
|
145
161
|
if (summary.error) {
|
|
146
|
-
console.log('\nRun "atris login" to
|
|
162
|
+
console.log('\nRun "atris login" to sign in.');
|
|
147
163
|
process.exit(1);
|
|
148
164
|
}
|
|
149
165
|
process.exit(0);
|
|
150
166
|
} catch (error) {
|
|
151
|
-
console.error(
|
|
167
|
+
console.error(`Failed to fetch account: ${error.message || error}`);
|
|
152
168
|
process.exit(1);
|
|
153
169
|
}
|
|
154
170
|
}
|
|
155
171
|
|
|
156
172
|
async function switchAccount() {
|
|
157
173
|
const args = process.argv.slice(3);
|
|
174
|
+
const globalFlag = args.includes('--global') || args.includes('-g');
|
|
158
175
|
const targetName = args.filter(a => !a.startsWith('-'))[0];
|
|
159
176
|
|
|
177
|
+
// Clean up stale session files in the background
|
|
178
|
+
cleanStaleSessions();
|
|
179
|
+
|
|
160
180
|
const profiles = listProfiles();
|
|
161
181
|
if (profiles.length === 0) {
|
|
162
|
-
console.log('No saved
|
|
163
|
-
console.log('Profiles are auto-saved on login.');
|
|
182
|
+
console.log('No saved accounts. Run "atris login" to add one.');
|
|
164
183
|
process.exit(1);
|
|
165
184
|
}
|
|
166
185
|
|
|
@@ -173,39 +192,50 @@ async function switchAccount() {
|
|
|
173
192
|
profiles.forEach((name, i) => {
|
|
174
193
|
const profile = loadProfile(name);
|
|
175
194
|
const email = profile?.email || 'unknown';
|
|
176
|
-
const marker = name === currentName ? '
|
|
195
|
+
const marker = name === currentName ? ' ← active' : '';
|
|
177
196
|
console.log(` ${i + 1}. ${name} — ${email}${marker}`);
|
|
178
197
|
});
|
|
179
|
-
console.log(` ${profiles.length + 1}.
|
|
198
|
+
console.log(` ${profiles.length + 1}. Add new account`);
|
|
199
|
+
console.log(` ${profiles.length + 2}. Cancel`);
|
|
180
200
|
|
|
181
|
-
const choice = await promptUser(`\
|
|
201
|
+
const choice = await promptUser(`\nChoice (1-${profiles.length + 2}): `);
|
|
182
202
|
const idx = parseInt(choice, 10) - 1;
|
|
183
203
|
|
|
204
|
+
if (idx === profiles.length) {
|
|
205
|
+
// Add new account
|
|
206
|
+
return loginAtris({ force: true });
|
|
207
|
+
}
|
|
208
|
+
|
|
184
209
|
if (isNaN(idx) || idx < 0 || idx >= profiles.length) {
|
|
185
210
|
console.log('Cancelled.');
|
|
186
211
|
process.exit(0);
|
|
187
212
|
}
|
|
188
213
|
|
|
189
214
|
const chosen = profiles[idx];
|
|
190
|
-
return activateProfile(chosen, currentName);
|
|
215
|
+
return activateProfile(chosen, currentName, { global: globalFlag });
|
|
191
216
|
}
|
|
192
217
|
|
|
193
218
|
// Direct: atris switch <name>
|
|
194
|
-
// Fuzzy match:
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
219
|
+
// Fuzzy match: exact → startsWith → substring → email substring
|
|
220
|
+
const q = targetName.toLowerCase();
|
|
221
|
+
const match = profiles.find(p => p.toLowerCase() === q)
|
|
222
|
+
|| profiles.find(p => p.toLowerCase().startsWith(q))
|
|
223
|
+
|| profiles.find(p => p.toLowerCase().includes(q))
|
|
224
|
+
|| profiles.find(p => {
|
|
225
|
+
const profile = loadProfile(p);
|
|
226
|
+
return profile?.email?.toLowerCase().includes(q);
|
|
227
|
+
});
|
|
198
228
|
|
|
199
229
|
if (!match) {
|
|
200
|
-
console.error(`
|
|
230
|
+
console.error(`No account matching "${targetName}".`);
|
|
201
231
|
console.log(`Available: ${profiles.join(', ')}`);
|
|
202
232
|
process.exit(1);
|
|
203
233
|
}
|
|
204
234
|
|
|
205
|
-
return activateProfile(match, currentName);
|
|
235
|
+
return activateProfile(match, currentName, { global: globalFlag });
|
|
206
236
|
}
|
|
207
237
|
|
|
208
|
-
function activateProfile(name, currentName) {
|
|
238
|
+
function activateProfile(name, currentName, { global = false } = {}) {
|
|
209
239
|
if (name === currentName) {
|
|
210
240
|
console.log(`Already on "${name}".`);
|
|
211
241
|
process.exit(0);
|
|
@@ -213,37 +243,257 @@ function activateProfile(name, currentName) {
|
|
|
213
243
|
|
|
214
244
|
const profile = loadProfile(name);
|
|
215
245
|
if (!profile || !profile.token) {
|
|
216
|
-
console.error(`Profile "${name}" is corrupted.
|
|
246
|
+
console.error(`Profile "${name}" is corrupted. Run "atris login" to fix.`);
|
|
217
247
|
process.exit(1);
|
|
218
248
|
}
|
|
219
249
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
250
|
+
if (global || !getTerminalSessionId()) {
|
|
251
|
+
// Global switch — write to credentials.json (affects all terminals)
|
|
252
|
+
const credentialsPath = getCredentialsPath();
|
|
253
|
+
fs.writeFileSync(credentialsPath, JSON.stringify(profile, null, 2));
|
|
254
|
+
try { fs.chmodSync(credentialsPath, 0o600); } catch {}
|
|
255
|
+
console.log(`Switched to ${profile.email || name} (global — all terminals)`);
|
|
256
|
+
} else {
|
|
257
|
+
// Per-terminal switch — write session file (only this terminal)
|
|
258
|
+
setSessionProfile(name);
|
|
259
|
+
console.log(`Switched to ${profile.email || name}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function useAccount() {
|
|
264
|
+
const args = process.argv.slice(3);
|
|
265
|
+
const targetName = args.filter(a => !a.startsWith('-'))[0];
|
|
266
|
+
|
|
267
|
+
if (!targetName) {
|
|
268
|
+
// Show current per-terminal override or global
|
|
269
|
+
const envProfile = process.env.ATRIS_PROFILE;
|
|
270
|
+
if (envProfile) {
|
|
271
|
+
const profile = loadProfile(envProfile);
|
|
272
|
+
const email = profile?.email || envProfile;
|
|
273
|
+
console.log(`This terminal: ${email} (ATRIS_PROFILE=${envProfile})`);
|
|
274
|
+
} else {
|
|
275
|
+
const current = loadCredentials();
|
|
276
|
+
if (current) {
|
|
277
|
+
console.log(`Global: ${current.email || 'unknown'} (no per-terminal override)`);
|
|
278
|
+
} else {
|
|
279
|
+
console.log('Not signed in.');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
console.log('\nSet per-terminal account:');
|
|
283
|
+
console.log(' eval "$(atris use <name>)"');
|
|
284
|
+
console.log('\nOr manually:');
|
|
285
|
+
console.log(' export ATRIS_PROFILE=<name>');
|
|
286
|
+
process.exit(0);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Fuzzy match the profile
|
|
290
|
+
const profiles = listProfiles();
|
|
291
|
+
const q = targetName.toLowerCase();
|
|
292
|
+
const match = profiles.find(p => p.toLowerCase() === q)
|
|
293
|
+
|| profiles.find(p => p.toLowerCase().startsWith(q))
|
|
294
|
+
|| profiles.find(p => p.toLowerCase().includes(q))
|
|
295
|
+
|| profiles.find(p => {
|
|
296
|
+
const profile = loadProfile(p);
|
|
297
|
+
return profile?.email?.toLowerCase().includes(q);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
if (!match) {
|
|
301
|
+
console.error(`No account matching "${targetName}".`);
|
|
302
|
+
console.error(`Available: ${profiles.join(', ')}`);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
224
305
|
|
|
225
|
-
|
|
306
|
+
const profile = loadProfile(match);
|
|
307
|
+
const email = profile?.email || match;
|
|
308
|
+
|
|
309
|
+
// If stdout is piped (eval mode), output just the export
|
|
310
|
+
if (!process.stdout.isTTY) {
|
|
311
|
+
process.stdout.write(`export ATRIS_PROFILE=${match}\n`);
|
|
312
|
+
} else {
|
|
313
|
+
// Interactive — print instructions
|
|
314
|
+
console.log(`export ATRIS_PROFILE=${match}`);
|
|
315
|
+
console.log(`\n# Run this to activate ${email} in this terminal:`);
|
|
316
|
+
console.log(`# eval "$(atris use ${targetName})"`);
|
|
317
|
+
console.log(`# Or just copy the export line above.`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function accountsCmd() {
|
|
322
|
+
const args = process.argv.slice(3);
|
|
323
|
+
const subCmd = args[0];
|
|
324
|
+
|
|
325
|
+
if (subCmd === 'add' || subCmd === 'login') {
|
|
326
|
+
return loginAtris({ force: true });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (subCmd === 'remove' || subCmd === 'rm') {
|
|
330
|
+
const target = args[1];
|
|
331
|
+
if (target === '--all') {
|
|
332
|
+
const profiles = listProfiles();
|
|
333
|
+
if (profiles.length === 0) {
|
|
334
|
+
console.log('No accounts to remove.');
|
|
335
|
+
process.exit(0);
|
|
336
|
+
}
|
|
337
|
+
const confirm = await promptUser(`Remove all ${profiles.length} accounts? (y/N): `);
|
|
338
|
+
if (confirm.toLowerCase() !== 'y') {
|
|
339
|
+
console.log('Cancelled.');
|
|
340
|
+
process.exit(0);
|
|
341
|
+
}
|
|
342
|
+
profiles.forEach(p => deleteProfile(p));
|
|
343
|
+
deleteCredentials();
|
|
344
|
+
console.log(`✓ Removed ${profiles.length} account(s).`);
|
|
345
|
+
process.exit(0);
|
|
346
|
+
}
|
|
347
|
+
if (!target) {
|
|
348
|
+
console.log('Usage: atris accounts remove <name>');
|
|
349
|
+
console.log(' atris accounts remove --all');
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
// Fuzzy match
|
|
353
|
+
const profiles = listProfiles();
|
|
354
|
+
const q = target.toLowerCase();
|
|
355
|
+
const match = profiles.find(p => p.toLowerCase() === q)
|
|
356
|
+
|| profiles.find(p => p.toLowerCase().startsWith(q))
|
|
357
|
+
|| profiles.find(p => p.toLowerCase().includes(q));
|
|
358
|
+
if (!match) {
|
|
359
|
+
console.error(`No account matching "${target}".`);
|
|
360
|
+
console.log(`Available: ${profiles.join(', ')}`);
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
const profile = loadProfile(match);
|
|
364
|
+
const email = profile?.email || 'unknown';
|
|
365
|
+
const confirm = await promptUser(`Remove ${match} (${email})? (y/N): `);
|
|
366
|
+
if (confirm.toLowerCase() !== 'y') {
|
|
367
|
+
console.log('Cancelled.');
|
|
368
|
+
process.exit(0);
|
|
369
|
+
}
|
|
370
|
+
deleteProfile(match);
|
|
371
|
+
// If this was the active account, clear credentials
|
|
372
|
+
const current = loadCredentials();
|
|
373
|
+
if (current && profileNameFromEmail(current.email) === match) {
|
|
374
|
+
deleteCredentials();
|
|
375
|
+
const remaining = listProfiles();
|
|
376
|
+
if (remaining.length > 0) {
|
|
377
|
+
console.log(`✓ Removed ${email}. No active account.`);
|
|
378
|
+
console.log(`Switch to another: atris switch ${remaining[0]}`);
|
|
379
|
+
} else {
|
|
380
|
+
console.log(`✓ Removed ${email}. No accounts remaining.`);
|
|
381
|
+
console.log('Run "atris login" to add one.');
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
console.log(`✓ Removed ${email}.`);
|
|
385
|
+
}
|
|
386
|
+
process.exit(0);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Default: list accounts
|
|
390
|
+
return listAccountsCmd();
|
|
226
391
|
}
|
|
227
392
|
|
|
228
393
|
function listAccountsCmd() {
|
|
229
394
|
const profiles = listProfiles();
|
|
230
395
|
if (profiles.length === 0) {
|
|
231
|
-
console.log('No saved
|
|
396
|
+
console.log('No accounts saved. Run "atris login" to add one.');
|
|
232
397
|
process.exit(0);
|
|
233
398
|
}
|
|
234
399
|
|
|
235
400
|
const current = loadCredentials();
|
|
236
|
-
const
|
|
401
|
+
const currentUid = current?.user_id;
|
|
402
|
+
// Also check which profile name is active (env var or session)
|
|
403
|
+
const envProfile = process.env.ATRIS_PROFILE;
|
|
404
|
+
const sessionProfile = getSessionProfile();
|
|
237
405
|
|
|
238
|
-
console.log('Accounts
|
|
406
|
+
console.log('\n Accounts\n');
|
|
239
407
|
profiles.forEach(name => {
|
|
240
408
|
const profile = loadProfile(name);
|
|
241
409
|
const email = profile?.email || 'unknown';
|
|
242
|
-
const
|
|
243
|
-
|
|
410
|
+
const isActive = profile?.user_id === currentUid;
|
|
411
|
+
if (isActive) {
|
|
412
|
+
console.log(` ● ${name} ${email}`);
|
|
413
|
+
} else {
|
|
414
|
+
console.log(` ${name} ${email}`);
|
|
415
|
+
}
|
|
244
416
|
});
|
|
245
|
-
console.log(
|
|
246
|
-
console.log(
|
|
417
|
+
console.log(`\n Switch: atris switch <name>`);
|
|
418
|
+
console.log(` Add: atris accounts add`);
|
|
419
|
+
console.log(` Remove: atris accounts remove <name>\n`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function resolveProfile() {
|
|
423
|
+
// Hidden command: atris _resolve <query> → prints resolved profile name
|
|
424
|
+
const query = process.argv[3];
|
|
425
|
+
if (!query) { process.exit(1); }
|
|
426
|
+
const profiles = listProfiles();
|
|
427
|
+
const q = query.toLowerCase();
|
|
428
|
+
const match = profiles.find(p => p.toLowerCase() === q)
|
|
429
|
+
|| profiles.find(p => p.toLowerCase().startsWith(q))
|
|
430
|
+
|| profiles.find(p => p.toLowerCase().includes(q))
|
|
431
|
+
|| profiles.find(p => {
|
|
432
|
+
const profile = loadProfile(p);
|
|
433
|
+
return profile?.email?.toLowerCase().includes(q);
|
|
434
|
+
});
|
|
435
|
+
if (match) {
|
|
436
|
+
process.stdout.write(match);
|
|
437
|
+
process.exit(0);
|
|
438
|
+
}
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function profileEmail() {
|
|
443
|
+
// Hidden command: atris _profile-email <name> → prints email
|
|
444
|
+
const name = process.argv[3];
|
|
445
|
+
if (!name) { process.exit(1); }
|
|
446
|
+
const profile = loadProfile(name);
|
|
447
|
+
if (profile?.email) {
|
|
448
|
+
process.stdout.write(profile.email);
|
|
449
|
+
process.exit(0);
|
|
450
|
+
}
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function shellInit() {
|
|
455
|
+
// Output shell function for per-terminal account switching
|
|
456
|
+
// Usage: eval "$(atris shell-init)" (add to ~/.zshrc)
|
|
457
|
+
// Use array join to avoid JS template literal parsing issues with ${}
|
|
458
|
+
const lines = [
|
|
459
|
+
'# Atris per-terminal account switching',
|
|
460
|
+
'# Added by: eval "$(atris shell-init)"',
|
|
461
|
+
'atris() {',
|
|
462
|
+
' if [[ "$1" == "switch" && -n "$2" && "$2" != "--"* ]]; then',
|
|
463
|
+
' local _profile',
|
|
464
|
+
' _profile=$(command atris _resolve "$2" 2>/dev/null)',
|
|
465
|
+
' if [[ $? -eq 0 && -n "$_profile" ]]; then',
|
|
466
|
+
' export ATRIS_PROFILE="$_profile"',
|
|
467
|
+
' local _email',
|
|
468
|
+
' _email=$(command atris _profile-email "$_profile" 2>/dev/null)',
|
|
469
|
+
' echo "Switched to ${_email:-$_profile}"',
|
|
470
|
+
' else',
|
|
471
|
+
' echo "No account matching \'$2\'."',
|
|
472
|
+
' command atris accounts',
|
|
473
|
+
' fi',
|
|
474
|
+
' elif [[ "$1" == "switch" && $# -eq 1 ]]; then',
|
|
475
|
+
' command atris accounts',
|
|
476
|
+
' echo ""',
|
|
477
|
+
' printf "Switch to: "',
|
|
478
|
+
' read _pick',
|
|
479
|
+
' if [[ -n "$_pick" ]]; then',
|
|
480
|
+
' local _profile',
|
|
481
|
+
' _profile=$(command atris _resolve "$_pick" 2>/dev/null)',
|
|
482
|
+
' if [[ $? -eq 0 && -n "$_profile" ]]; then',
|
|
483
|
+
' export ATRIS_PROFILE="$_profile"',
|
|
484
|
+
' local _email',
|
|
485
|
+
' _email=$(command atris _profile-email "$_profile" 2>/dev/null)',
|
|
486
|
+
' echo "Switched to ${_email:-$_profile}"',
|
|
487
|
+
' else',
|
|
488
|
+
' echo "No account matching \'$_pick\'."',
|
|
489
|
+
' fi',
|
|
490
|
+
' fi',
|
|
491
|
+
' else',
|
|
492
|
+
' command atris "$@"',
|
|
493
|
+
' fi',
|
|
494
|
+
'}',
|
|
495
|
+
];
|
|
496
|
+
console.log(lines.join('\n'));
|
|
247
497
|
}
|
|
248
498
|
|
|
249
|
-
module.exports = { loginAtris, logoutAtris, whoamiAtris, switchAccount, listAccountsCmd };
|
|
499
|
+
module.exports = { loginAtris, logoutAtris, whoamiAtris, switchAccount, useAccount, accountsCmd, listAccountsCmd, resolveProfile, profileEmail, shellInit };
|
package/package.json
CHANGED
package/utils/auth.js
CHANGED
|
@@ -124,6 +124,101 @@ function getCredentialsPath() {
|
|
|
124
124
|
return path.join(getAtrisDir(), 'credentials.json');
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
function getSessionsDir() {
|
|
128
|
+
const dir = path.join(getAtrisDir(), 'sessions');
|
|
129
|
+
if (!fs.existsSync(dir)) {
|
|
130
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
131
|
+
}
|
|
132
|
+
return dir;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getTerminalSessionId() {
|
|
136
|
+
// Unique per terminal window/tab — works across macOS terminals, tmux, VS Code, Ghostty
|
|
137
|
+
const envId = process.env.TERM_SESSION_ID // macOS Terminal.app
|
|
138
|
+
|| process.env.ITERM_SESSION_ID // iTerm2
|
|
139
|
+
|| process.env.TMUX_PANE // tmux pane
|
|
140
|
+
|| process.env.WT_SESSION // Windows Terminal
|
|
141
|
+
|| process.env.WEZTERM_PANE; // WezTerm
|
|
142
|
+
if (envId) return envId;
|
|
143
|
+
|
|
144
|
+
// Universal fallback: TTY device name (unique per terminal tab on macOS/Linux)
|
|
145
|
+
// Each Ghostty/iTerm/Terminal tab gets a unique /dev/ttysNNN
|
|
146
|
+
try {
|
|
147
|
+
// Method 1: check if stdin is a TTY and resolve its path
|
|
148
|
+
if (process.stdin.isTTY) {
|
|
149
|
+
const resolved = fs.realpathSync('/dev/stdin');
|
|
150
|
+
if (resolved && resolved.startsWith('/dev/tty')) return resolved;
|
|
151
|
+
}
|
|
152
|
+
} catch {}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// Method 2: shell out to tty command
|
|
156
|
+
const { execSync } = require('child_process');
|
|
157
|
+
const tty = execSync('tty', { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] }).trim();
|
|
158
|
+
if (tty && tty !== 'not a tty' && tty.startsWith('/dev/')) return tty;
|
|
159
|
+
} catch {}
|
|
160
|
+
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sanitizeSessionId(id) {
|
|
165
|
+
// Make filesystem-safe: replace non-alphanumeric with dashes, truncate
|
|
166
|
+
return id.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getSessionFilePath() {
|
|
170
|
+
const sessionId = getTerminalSessionId();
|
|
171
|
+
if (!sessionId) return null;
|
|
172
|
+
return path.join(getSessionsDir(), `${sanitizeSessionId(sessionId)}.json`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function setSessionProfile(profileName) {
|
|
176
|
+
const sessionPath = getSessionFilePath();
|
|
177
|
+
if (!sessionPath) {
|
|
178
|
+
// No terminal session ID — fall back to global switch
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
fs.writeFileSync(sessionPath, JSON.stringify({
|
|
182
|
+
profile: profileName,
|
|
183
|
+
set_at: new Date().toISOString(),
|
|
184
|
+
}));
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getSessionProfile() {
|
|
189
|
+
const sessionPath = getSessionFilePath();
|
|
190
|
+
if (!sessionPath || !fs.existsSync(sessionPath)) return null;
|
|
191
|
+
try {
|
|
192
|
+
const data = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
|
|
193
|
+
return data.profile || null;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function clearSessionProfile() {
|
|
200
|
+
const sessionPath = getSessionFilePath();
|
|
201
|
+
if (sessionPath && fs.existsSync(sessionPath)) {
|
|
202
|
+
fs.unlinkSync(sessionPath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function cleanStaleSessions() {
|
|
207
|
+
// Remove session files older than 7 days
|
|
208
|
+
const dir = path.join(getAtrisDir(), 'sessions');
|
|
209
|
+
if (!fs.existsSync(dir)) return;
|
|
210
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
211
|
+
try {
|
|
212
|
+
for (const f of fs.readdirSync(dir)) {
|
|
213
|
+
const fp = path.join(dir, f);
|
|
214
|
+
try {
|
|
215
|
+
const stat = fs.statSync(fp);
|
|
216
|
+
if (stat.mtimeMs < cutoff) fs.unlinkSync(fp);
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
|
|
127
222
|
function getProfilesDir() {
|
|
128
223
|
const dir = path.join(getAtrisDir(), 'profiles');
|
|
129
224
|
if (!fs.existsSync(dir)) {
|
|
@@ -205,6 +300,32 @@ function saveCredentials(token, refreshToken, email, userId, provider) {
|
|
|
205
300
|
}
|
|
206
301
|
|
|
207
302
|
function loadCredentials() {
|
|
303
|
+
// Priority: ATRIS_PROFILE env var → per-terminal session file → global credentials.json
|
|
304
|
+
|
|
305
|
+
// 1. Explicit env var override
|
|
306
|
+
const profileOverride = process.env.ATRIS_PROFILE;
|
|
307
|
+
if (profileOverride) {
|
|
308
|
+
const profile = loadProfile(profileOverride);
|
|
309
|
+
if (profile) return profile;
|
|
310
|
+
const profiles = listProfiles();
|
|
311
|
+
const q = profileOverride.toLowerCase();
|
|
312
|
+
const match = profiles.find(p => p.toLowerCase() === q)
|
|
313
|
+
|| profiles.find(p => p.toLowerCase().startsWith(q))
|
|
314
|
+
|| profiles.find(p => p.toLowerCase().includes(q));
|
|
315
|
+
if (match) {
|
|
316
|
+
const matched = loadProfile(match);
|
|
317
|
+
if (matched) return matched;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 2. Per-terminal session override (set by atris switch)
|
|
322
|
+
const sessionProfile = getSessionProfile();
|
|
323
|
+
if (sessionProfile) {
|
|
324
|
+
const profile = loadProfile(sessionProfile);
|
|
325
|
+
if (profile) return profile;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 3. Global credentials.json
|
|
208
329
|
const credentialsPath = getCredentialsPath();
|
|
209
330
|
|
|
210
331
|
if (!fs.existsSync(credentialsPath)) {
|
|
@@ -456,4 +577,10 @@ module.exports = {
|
|
|
456
577
|
deleteProfile,
|
|
457
578
|
profileNameFromEmail,
|
|
458
579
|
autoSaveProfile,
|
|
580
|
+
// Per-terminal sessions
|
|
581
|
+
getTerminalSessionId,
|
|
582
|
+
setSessionProfile,
|
|
583
|
+
getSessionProfile,
|
|
584
|
+
clearSessionProfile,
|
|
585
|
+
cleanStaleSessions,
|
|
459
586
|
};
|