atris 2.6.1 → 2.6.2

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/bin/atris.js CHANGED
@@ -211,6 +211,7 @@ function showHelp() {
211
211
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
212
212
  console.log('');
213
213
  console.log('Setup:');
214
+ console.log(' setup - Guided first-time setup (login, pick business, pull)');
214
215
  console.log(' init - Initialize Atris in current project');
215
216
  console.log(' update - Update local files to latest version');
216
217
  console.log(' upgrade - Install latest Atris from npm');
@@ -246,6 +247,14 @@ function showHelp() {
246
247
  console.log('');
247
248
  console.log('Sync:');
248
249
  console.log(' pull - Pull journals + member data from cloud');
250
+ console.log(' clean-workspace <slug> - Analyze & remove junk files from a workspace (alias: cw)');
251
+ console.log('');
252
+ console.log('Business:');
253
+ console.log(' business add <slug> - Connect a business');
254
+ console.log(' business list - Show connected businesses');
255
+ console.log(' business remove <slug> - Disconnect a business');
256
+ console.log(' business health <slug> - Health report (members, workspace, issues)');
257
+ console.log(' business audit - One-line health summary of all businesses');
249
258
  console.log('');
250
259
  console.log('Cloud & agents:');
251
260
  console.log(' console - Start/attach always-on coding console (tmux daemon)');
@@ -387,7 +396,7 @@ const { planAtris: planCmd, doAtris: doCmd, reviewAtris: reviewCmd } = require('
387
396
  const knownCommands = ['init', 'log', 'status', 'analytics', 'visualize', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review',
388
397
  'activate', 'agent', 'chat', 'console', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
389
398
  'clean', 'verify', 'search', 'skill', 'member', 'plugin', 'experiments', 'pull', 'push', 'business', 'sync',
390
- 'gmail', 'calendar', 'twitter', 'slack', 'integrations'];
399
+ 'gmail', 'calendar', 'twitter', 'slack', 'integrations', 'setup', 'clean-workspace', 'cw'];
391
400
 
392
401
  // Check if command is an atris.md spec file - triggers welcome visualization
393
402
  function isSpecFile(cmd) {
@@ -958,6 +967,11 @@ if (command === 'init') {
958
967
  require('../commands/business').businessCommand(subcommand, ...args)
959
968
  .then(() => process.exit(0))
960
969
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
970
+ } else if (command === 'clean-workspace' || command === 'cw') {
971
+ const { cleanWorkspace } = require('../commands/workspace-clean');
972
+ cleanWorkspace()
973
+ .then(() => process.exit(0))
974
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
961
975
  } else if (command === 'plugin') {
962
976
  const subcommand = process.argv[3] || 'build';
963
977
  const args = process.argv.slice(4);
@@ -966,6 +980,10 @@ if (command === 'init') {
966
980
  const subcommand = process.argv[3];
967
981
  const args = process.argv.slice(4);
968
982
  require('../commands/experiments').experimentsCommand(subcommand, ...args);
983
+ } else if (command === 'setup') {
984
+ require('../commands/setup').setupAtris()
985
+ .then(() => process.exit(0))
986
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
969
987
  } else {
970
988
  console.log(`Unknown command: ${command}`);
971
989
  console.log('Run "atris help" to see available commands');
@@ -111,6 +111,242 @@ async function removeBusiness(slug) {
111
111
  console.log(`\nRemoved "${name}"`);
112
112
  }
113
113
 
114
+ // ---------------------------------------------------------------------------
115
+ // Resolve a slug to a business ID using local cache or API lookup
116
+ // ---------------------------------------------------------------------------
117
+ async function resolveSlug(slug, creds) {
118
+ // Check local cache first
119
+ const businesses = loadBusinesses();
120
+ if (businesses[slug]) {
121
+ return businesses[slug];
122
+ }
123
+
124
+ // Try by-slug endpoint
125
+ const result = await apiRequestJson(`/businesses/by-slug/${slug}/`, {
126
+ method: 'GET',
127
+ token: creds.token,
128
+ });
129
+ if (result.ok && result.data) {
130
+ return { business_id: result.data.id, workspace_id: result.data.workspace_id, name: result.data.name, slug: result.data.slug };
131
+ }
132
+
133
+ // Fallback: list all and match
134
+ const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
135
+ if (listResult.ok && Array.isArray(listResult.data)) {
136
+ const match = listResult.data.find(b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase());
137
+ if (match) {
138
+ return { business_id: match.id, workspace_id: match.workspace_id, name: match.name, slug: match.slug };
139
+ }
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Helper: format relative time
147
+ // ---------------------------------------------------------------------------
148
+ function relativeTime(dateStr) {
149
+ if (!dateStr) return 'unknown';
150
+ const diff = Date.now() - new Date(dateStr).getTime();
151
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
152
+ if (days <= 0) return 'today';
153
+ if (days === 1) return '1d ago';
154
+ return `${days}d ago`;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Helper: activity bar
159
+ // ---------------------------------------------------------------------------
160
+ function activityBar(daysSinceActive, width = 10) {
161
+ const filled = Math.max(0, Math.min(width, width - Math.floor(daysSinceActive / 3)));
162
+ return '\u2501'.repeat(filled) + '\u2591'.repeat(width - filled);
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // atris business health <slug>
167
+ // ---------------------------------------------------------------------------
168
+ async function businessHealth(slug) {
169
+ if (!slug) {
170
+ console.error('Usage: atris business health <slug>');
171
+ process.exit(1);
172
+ }
173
+
174
+ const creds = loadCredentials();
175
+ if (!creds || !creds.token) {
176
+ console.error('Not logged in. Run: atris login');
177
+ process.exit(1);
178
+ }
179
+
180
+ const biz = await resolveSlug(slug, creds);
181
+ if (!biz) {
182
+ console.error(`Business "${slug}" not found.`);
183
+ process.exit(1);
184
+ }
185
+
186
+ const bizId = biz.business_id;
187
+ const wsId = biz.workspace_id;
188
+
189
+ // Fetch dashboard and workspace snapshot in parallel
190
+ const fetchOpts = { method: 'GET', token: creds.token, timeoutMs: 120000 };
191
+ const [dashResult, wsResult] = await Promise.all([
192
+ apiRequestJson(`/businesses/${bizId}/dashboard/`, fetchOpts),
193
+ wsId
194
+ ? apiRequestJson(`/businesses/${bizId}/workspaces/${wsId}/snapshot?include_content=false`, fetchOpts)
195
+ : Promise.resolve({ ok: false }),
196
+ ]);
197
+
198
+ const dashboard = dashResult.ok ? dashResult.data : null;
199
+ const workspace = wsResult.ok ? wsResult.data : null;
200
+
201
+ const name = dashboard?.business?.name || biz.name || slug;
202
+
203
+ console.log('');
204
+ console.log(`Business Health: ${name}`);
205
+ console.log('\u2501'.repeat(26 + name.length));
206
+ console.log('');
207
+
208
+ // Workspace stats
209
+ const files = workspace?.files || [];
210
+ const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
211
+ const fileSizeStr = totalSize > 1024 ? `${Math.round(totalSize / 1024)}KB` : `${totalSize}B`;
212
+ console.log(` Workspace: ${files.length} files, ${fileSizeStr}`);
213
+
214
+ // Members
215
+ const members = dashboard?.roster?.members || dashboard?.members || dashboard?.business?.members || [];
216
+ const humanMembers = members.filter(m => !m.is_agent && m.role !== 'agent');
217
+ const agentMembers = members.filter(m => m.is_agent || m.role === 'agent');
218
+ const memberCountStr = members.length > 0
219
+ ? `${members.length} (${humanMembers.length} human, ${agentMembers.length} agent)`
220
+ : `${members.length}`;
221
+ console.log(` Members: ${memberCountStr}`);
222
+
223
+ // Apps
224
+ const apps = dashboard?.business?.apps || dashboard?.apps || [];
225
+ console.log(` Apps: ${Array.isArray(apps) ? apps.length : 0}`);
226
+
227
+ // Status
228
+ const status = dashboard?.business?.status || dashboard?.status || 'unknown';
229
+ console.log(` Status: ${status}`);
230
+
231
+ // Member activity
232
+ if (members.length > 0) {
233
+ console.log('');
234
+ console.log(' Member Activity:');
235
+ for (const m of members) {
236
+ const memberName = m.display_name || m.name || m.email || 'Unknown';
237
+ const role = m.role || 'member';
238
+ const lastActive = m.atris?.last_active || m.last_active || m.last_login || m.joined_at || m.created_at;
239
+ const daysSince = lastActive ? Math.floor((Date.now() - new Date(lastActive).getTime()) / (1000 * 60 * 60 * 24)) : 999;
240
+ const bar = activityBar(daysSince);
241
+ const label = daysSince <= 1 ? 'active' : `last active ${relativeTime(lastActive)}`;
242
+ console.log(` ${memberName.padEnd(18)} ${role.padEnd(8)} ${bar} ${label}`);
243
+ }
244
+ }
245
+
246
+ // Workspace breakdown by directory
247
+ if (files.length > 0) {
248
+ console.log('');
249
+ console.log(' Workspace Breakdown:');
250
+ const dirSizes = {};
251
+ for (const f of files) {
252
+ const filePath = f.path || f.name || '';
253
+ const dir = filePath.includes('/') ? filePath.split('/')[0] + '/' : '/';
254
+ dirSizes[dir] = (dirSizes[dir] || 0) + (f.size || 0);
255
+ }
256
+ const maxDirSize = Math.max(...Object.values(dirSizes), 1);
257
+ const sortedDirs = Object.entries(dirSizes).sort((a, b) => b[1] - a[1]);
258
+ for (const [dir, size] of sortedDirs) {
259
+ const sizeStr = size > 1024 ? `${Math.round(size / 1024)}KB` : `${size}B`;
260
+ const barLen = Math.max(1, Math.round((size / maxDirSize) * 10));
261
+ console.log(` ${dir.padEnd(12)} ${sizeStr.padStart(5)} ${'█'.repeat(barLen)}`);
262
+ }
263
+ }
264
+
265
+ // Issues
266
+ console.log('');
267
+ console.log(' Issues:');
268
+ let hasIssues = false;
269
+ const humanMembers2 = members.filter(m => m.role !== 'agent');
270
+ for (const m of humanMembers2) {
271
+ const lastActive = m.atris?.last_active || m.last_active || m.last_login || m.joined_at || m.created_at;
272
+ const daysSince = lastActive ? Math.floor((Date.now() - new Date(lastActive).getTime()) / (1000 * 60 * 60 * 24)) : 999;
273
+ if (daysSince >= 30) {
274
+ const memberName = m.display_name || m.name || m.email || 'Unknown';
275
+ console.log(` \u26A0 ${memberName} inactive for ${daysSince}+ days`);
276
+ hasIssues = true;
277
+ }
278
+ }
279
+
280
+ // Check for workspace bloat (arbitrary threshold: >500KB or >100 files)
281
+ if (totalSize > 500 * 1024) {
282
+ console.log(` \u26A0 Workspace large (${fileSizeStr})`);
283
+ hasIssues = true;
284
+ }
285
+ if (files.length > 100) {
286
+ console.log(` \u26A0 Workspace has ${files.length} files (consider cleanup)`);
287
+ hasIssues = true;
288
+ }
289
+ if (!hasIssues) {
290
+ console.log(' \u2713 Workspace clean (no bloat detected)');
291
+ }
292
+
293
+ console.log('');
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // atris business audit
298
+ // ---------------------------------------------------------------------------
299
+ async function businessAudit() {
300
+ const creds = loadCredentials();
301
+ if (!creds || !creds.token) {
302
+ console.error('Not logged in. Run: atris login');
303
+ process.exit(1);
304
+ }
305
+
306
+ const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
307
+ if (!listResult.ok || !Array.isArray(listResult.data)) {
308
+ console.error(`Failed to fetch businesses: ${listResult.error || 'unknown error'}`);
309
+ process.exit(1);
310
+ }
311
+
312
+ const businesses = listResult.data;
313
+
314
+ console.log('');
315
+ console.log('Business Audit');
316
+ console.log('\u2501'.repeat(14));
317
+ console.log('');
318
+
319
+ for (const biz of businesses) {
320
+ const name = biz.name || biz.slug || 'Unknown';
321
+ const memberCount = typeof biz.member_count === 'number' ? biz.member_count : (Array.isArray(biz.members) ? biz.members.length : 0);
322
+ const appCount = typeof biz.app_count === 'number' ? biz.app_count : (Array.isArray(biz.apps) ? biz.apps.length : 0);
323
+
324
+ // Determine activity status
325
+ const status = biz.status || 'unknown';
326
+ const isActive = status === 'active' || (memberCount > 1 && appCount > 0);
327
+ const hasContent = memberCount > 1 || appCount > 0;
328
+
329
+ let icon, activityLabel;
330
+ if (isActive) {
331
+ icon = '\u2713';
332
+ activityLabel = appCount > 0 ? 'active' : 'idle';
333
+ } else if (hasContent) {
334
+ icon = '\u26A0';
335
+ activityLabel = 'inactive';
336
+ } else {
337
+ icon = '\u25CB';
338
+ activityLabel = 'inactive';
339
+ }
340
+
341
+ const memberStr = memberCount === 1 ? '1 member' : `${memberCount} members`;
342
+ const appStr = appCount === 1 ? '1 app' : `${appCount} apps`;
343
+
344
+ console.log(` ${icon} ${name.padEnd(16)} ${memberStr.padEnd(12)} ${appStr.padEnd(8)} ${activityLabel}`);
345
+ }
346
+
347
+ console.log('');
348
+ }
349
+
114
350
  async function businessCommand(subcommand, ...args) {
115
351
  switch (subcommand) {
116
352
  case 'add':
@@ -124,9 +360,15 @@ async function businessCommand(subcommand, ...args) {
124
360
  case 'rm':
125
361
  await removeBusiness(args[0]);
126
362
  break;
363
+ case 'health':
364
+ await businessHealth(args[0]);
365
+ break;
366
+ case 'audit':
367
+ await businessAudit();
368
+ break;
127
369
  default:
128
- console.log('Usage: atris business <add|list|remove> [slug]');
370
+ console.log('Usage: atris business <add|list|remove|health|audit> [slug]');
129
371
  }
130
372
  }
131
373
 
132
- module.exports = { businessCommand, loadBusinesses, saveBusinesses, getBusinessConfigPath };
374
+ module.exports = { businessCommand, businessHealth, businessAudit, loadBusinesses, saveBusinesses, getBusinessConfigPath };
@@ -73,10 +73,10 @@ async function businessStatus(slug) {
73
73
  if (fs.existsSync(atrisDir)) localDir = atrisDir;
74
74
  else if (fs.existsSync(cwdDir)) localDir = cwdDir;
75
75
 
76
- // Get remote snapshot (hashes only)
76
+ // Get remote snapshot with content (needed for reliable hash computation)
77
77
  const result = await apiRequestJson(
78
- `/businesses/${biz.businessId}/workspaces/${biz.workspaceId}/snapshot?include_content=false`,
79
- { method: 'GET', token: biz.token }
78
+ `/businesses/${biz.businessId}/workspaces/${biz.workspaceId}/snapshot?include_content=true`,
79
+ { method: 'GET', token: biz.token, timeoutMs: 120000 }
80
80
  );
81
81
 
82
82
  if (!result.ok) {
@@ -89,9 +89,11 @@ async function businessStatus(slug) {
89
89
  }
90
90
 
91
91
  const remoteFiles = {};
92
+ const crypto = require('crypto');
92
93
  for (const file of (result.data.files || [])) {
93
- if (file.path && !file.binary) {
94
- remoteFiles[file.path] = { hash: file.hash, size: file.size || 0 };
94
+ if (file.path && !file.binary && file.content != null) {
95
+ const rawBytes = Buffer.from(file.content, 'utf-8');
96
+ remoteFiles[file.path] = { hash: crypto.createHash('sha256').update(rawBytes).digest('hex'), size: rawBytes.length };
95
97
  }
96
98
  }
97
99
 
package/commands/pull.js CHANGED
@@ -13,7 +13,7 @@ async function pullAtris() {
13
13
  const arg = process.argv[3];
14
14
 
15
15
  // If a business name is given, do a business pull
16
- if (arg && arg !== '--help') {
16
+ if (arg && arg !== '--help' && !arg.startsWith('--')) {
17
17
  return pullBusiness(arg);
18
18
  }
19
19
 
@@ -80,6 +80,23 @@ async function pullBusiness(slug) {
80
80
 
81
81
  const force = process.argv.includes('--force');
82
82
 
83
+ // Parse --only flag: comma-separated directory prefixes to filter
84
+ const onlyArg = process.argv.find(a => a.startsWith('--only='));
85
+ const onlyPrefixes = onlyArg
86
+ ? onlyArg.slice('--only='.length).split(',').map(p => {
87
+ // Normalize: strip leading slash, ensure trailing slash for dirs
88
+ let norm = p.replace(/^\//, '');
89
+ if (norm && !norm.endsWith('/') && !norm.includes('.')) norm += '/';
90
+ return norm;
91
+ }).filter(Boolean)
92
+ : null;
93
+
94
+ // Parse --timeout flag: override default 120s timeout
95
+ const timeoutArg = process.argv.find(a => a.startsWith('--timeout='));
96
+ const timeoutMs = timeoutArg
97
+ ? parseInt(timeoutArg.slice('--timeout='.length), 10) * 1000
98
+ : 120000;
99
+
83
100
  // Determine output directory
84
101
  const intoIdx = process.argv.indexOf('--into');
85
102
  let outputDir;
@@ -94,21 +111,23 @@ async function pullBusiness(slug) {
94
111
  }
95
112
  }
96
113
 
97
- // Resolve business ID — check local config first, then API
114
+ // Resolve business ID — always refresh from API to avoid stale workspace_id
98
115
  let businessId, workspaceId, businessName, resolvedSlug;
99
116
  const businesses = loadBusinesses();
100
117
 
101
- if (businesses[slug]) {
102
- businessId = businesses[slug].business_id;
103
- workspaceId = businesses[slug].workspace_id;
104
- businessName = businesses[slug].name || slug;
105
- resolvedSlug = businesses[slug].slug || slug;
106
- } else {
107
- const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
108
- if (!listResult.ok) {
118
+ const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
119
+ if (!listResult.ok) {
120
+ // Fall back to local cache if API fails
121
+ if (businesses[slug]) {
122
+ businessId = businesses[slug].business_id;
123
+ workspaceId = businesses[slug].workspace_id;
124
+ businessName = businesses[slug].name || slug;
125
+ resolvedSlug = businesses[slug].slug || slug;
126
+ } else {
109
127
  console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
110
128
  process.exit(1);
111
129
  }
130
+ } else {
112
131
  const match = (listResult.data || []).find(
113
132
  b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase()
114
133
  );
@@ -121,6 +140,7 @@ async function pullBusiness(slug) {
121
140
  businessName = match.name;
122
141
  resolvedSlug = match.slug;
123
142
 
143
+ // Update local cache
124
144
  businesses[slug] = {
125
145
  business_id: businessId,
126
146
  workspace_id: workspaceId,
@@ -143,16 +163,19 @@ async function pullBusiness(slug) {
143
163
 
144
164
  console.log('');
145
165
  console.log(`Pulling ${businessName}...` + (timeSince ? ` (last synced ${timeSince})` : ''));
166
+ console.log(' Fetching workspace...');
146
167
 
147
168
  // Get remote snapshot (large workspaces can take 60s+)
148
169
  const result = await apiRequestJson(
149
170
  `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
150
- { method: 'GET', token: creds.token, timeoutMs: 120000 }
171
+ { method: 'GET', token: creds.token, timeoutMs }
151
172
  );
152
173
 
153
174
  if (!result.ok) {
154
- const msg = result.errorMessage || `HTTP ${result.status}`;
155
- if (result.status === 409) {
175
+ const msg = result.errorMessage || result.error || `HTTP ${result.status}`;
176
+ if (result.status === 0 || (typeof msg === 'string' && msg.toLowerCase().includes('timeout'))) {
177
+ console.error(`\n Workspace is taking too long to respond. Try: atris pull ${slug} --timeout=120`);
178
+ } else if (result.status === 409) {
156
179
  console.error(`\n Computer is sleeping. Wake it first, then pull again.`);
157
180
  } else if (result.status === 403) {
158
181
  console.error(`\n Access denied. You're not a member of "${slug}".`);
@@ -164,18 +187,39 @@ async function pullBusiness(slug) {
164
187
  process.exit(1);
165
188
  }
166
189
 
167
- const files = result.data.files || [];
190
+ let files = result.data.files || [];
168
191
  if (files.length === 0) {
169
192
  console.log(' Workspace is empty.');
170
193
  return;
171
194
  }
172
195
 
196
+ console.log(` Processing ${files.length} files...`);
197
+
198
+ // Apply --only filter if specified
199
+ if (onlyPrefixes) {
200
+ files = files.filter(file => {
201
+ if (!file.path) return false;
202
+ const rel = file.path.replace(/^\//, '');
203
+ return onlyPrefixes.some(prefix => rel.startsWith(prefix));
204
+ });
205
+ if (files.length === 0) {
206
+ console.log(` No files matched --only filter: ${onlyPrefixes.join(', ')}`);
207
+ return;
208
+ }
209
+ console.log(` Filtered to ${files.length} files matching: ${onlyPrefixes.join(', ')}`);
210
+ }
211
+
173
212
  // Build remote file map {path: {hash, size, content}}
174
213
  const remoteFiles = {};
175
214
  const remoteContent = {};
176
215
  for (const file of files) {
177
216
  if (!file.path || file.binary || file.content === null || file.content === undefined) continue;
178
- remoteFiles[file.path] = { hash: file.hash || computeFileHash(file.content), size: file.size || 0 };
217
+ // Skip empty files (deleted files that were blanked out)
218
+ if (file.content === '') continue;
219
+ // Compute hash from content bytes (matches computeLocalHashes raw byte hashing)
220
+ const crypto = require('crypto');
221
+ const rawBytes = Buffer.from(file.content, 'utf-8');
222
+ remoteFiles[file.path] = { hash: crypto.createHash('sha256').update(rawBytes).digest('hex'), size: rawBytes.length };
179
223
  remoteContent[file.path] = file.content;
180
224
  }
181
225
 
@@ -265,8 +309,12 @@ async function pullBusiness(slug) {
265
309
  // Git might not be initialized yet — that's fine
266
310
  }
267
311
 
268
- // Save manifest
269
- const newManifest = buildManifest(remoteFiles, commitHash);
312
+ // Save manifest — when using --only, merge into existing manifest to avoid data loss
313
+ let manifestFiles = remoteFiles;
314
+ if (onlyPrefixes && manifest && manifest.files) {
315
+ manifestFiles = { ...manifest.files, ...remoteFiles };
316
+ }
317
+ const newManifest = buildManifest(manifestFiles, commitHash);
270
318
  saveManifest(resolvedSlug || slug, newManifest);
271
319
  }
272
320
 
package/commands/push.js CHANGED
@@ -114,16 +114,20 @@ async function pushAtris() {
114
114
  console.log('');
115
115
  console.log(`Pushing to ${businessName}...`);
116
116
 
117
+ // Get snapshot with content to compute reliable hashes (server hash may differ)
117
118
  const snapshotResult = await apiRequestJson(
118
- `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=false`,
119
- { method: 'GET', token: creds.token }
119
+ `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
120
+ { method: 'GET', token: creds.token, timeoutMs: 120000 }
120
121
  );
121
122
 
122
123
  let remoteFiles = {};
123
124
  if (snapshotResult.ok && snapshotResult.data && snapshotResult.data.files) {
124
125
  for (const file of snapshotResult.data.files) {
125
- if (file.path && !file.binary) {
126
- remoteFiles[file.path] = { hash: file.hash, size: file.size || 0 };
126
+ if (file.path && !file.binary && file.content != null) {
127
+ // Compute hash from content (matches how computeLocalHashes works on raw bytes)
128
+ const rawBytes = Buffer.from(file.content, 'utf-8');
129
+ const hash = require('crypto').createHash('sha256').update(rawBytes).digest('hex');
130
+ remoteFiles[file.path] = { hash, size: rawBytes.length };
127
131
  }
128
132
  }
129
133
  }
@@ -0,0 +1,178 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { loadCredentials, promptUser } = require('../utils/auth');
4
+ const { apiRequestJson } = require('../utils/api');
5
+
6
+ async function setupAtris() {
7
+ console.log('');
8
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
9
+ console.log(' Atris Setup');
10
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
11
+ console.log('');
12
+
13
+ // Step 1: Check Node version
14
+ const nodeVersion = process.versions.node;
15
+ const major = parseInt(nodeVersion.split('.')[0], 10);
16
+ if (major < 18) {
17
+ console.error(`Node.js ${nodeVersion} is too old. Atris requires Node.js 18 or newer.`);
18
+ console.error('');
19
+ console.error('Update Node.js:');
20
+ console.error(' macOS: brew install node');
21
+ console.error(' or visit https://nodejs.org/en/download');
22
+ process.exit(1);
23
+ }
24
+ console.log(` [1/4] Node.js ${nodeVersion} ... OK`);
25
+
26
+ // Step 2: Check login status
27
+ let creds = loadCredentials();
28
+ if (creds && creds.token) {
29
+ const label = creds.email || creds.user_id || 'unknown';
30
+ console.log(` [2/4] Logged in as ${label} ... OK`);
31
+ } else {
32
+ console.log(' [2/4] Not logged in. Starting login...');
33
+ console.log('');
34
+ const { loginAtris } = require('./auth');
35
+ // loginAtris calls process.exit, so we override it temporarily
36
+ const originalExit = process.exit;
37
+ let loginCompleted = false;
38
+ process.exit = (code) => {
39
+ if (code === 0) {
40
+ loginCompleted = true;
41
+ return; // Suppress exit on success so setup can continue
42
+ }
43
+ // On failure, actually exit
44
+ originalExit(code);
45
+ };
46
+ try {
47
+ await loginAtris();
48
+ } finally {
49
+ process.exit = originalExit;
50
+ }
51
+
52
+ if (!loginCompleted) {
53
+ console.error('\nLogin failed. Run "atris setup" again after fixing the issue.');
54
+ process.exit(1);
55
+ }
56
+
57
+ // Reload credentials after login
58
+ creds = loadCredentials();
59
+ if (!creds || !creds.token) {
60
+ console.error('\nLogin did not produce credentials. Run "atris login" manually, then "atris setup" again.');
61
+ process.exit(1);
62
+ }
63
+ console.log('');
64
+ console.log(` [2/4] Logged in ... OK`);
65
+ }
66
+
67
+ // Step 3: Fetch businesses
68
+ console.log(' [3/4] Fetching your businesses...');
69
+ let businesses = [];
70
+ try {
71
+ const result = await apiRequestJson('/businesses/', {
72
+ method: 'GET',
73
+ token: creds.token,
74
+ });
75
+
76
+ if (!result.ok) {
77
+ console.error(`\n Could not fetch businesses: ${result.error || 'Unknown error'}`);
78
+ console.error(' You can add one later with: atris business add <slug>');
79
+ console.log('');
80
+ printFinished();
81
+ return;
82
+ }
83
+
84
+ businesses = Array.isArray(result.data) ? result.data : [];
85
+ } catch (err) {
86
+ console.error(`\n Could not fetch businesses: ${err.message || err}`);
87
+ console.error(' You can add one later with: atris business add <slug>');
88
+ console.log('');
89
+ printFinished();
90
+ return;
91
+ }
92
+
93
+ if (businesses.length === 0) {
94
+ console.log('\n No businesses found on your account.');
95
+ console.log(' Create one at https://atris.ai or ask your team admin for access.');
96
+ console.log('');
97
+ printFinished();
98
+ return;
99
+ }
100
+
101
+ // Step 4: List businesses and let user pick
102
+ console.log('');
103
+ console.log(' Your businesses:');
104
+ businesses.forEach((b, i) => {
105
+ const name = b.name || b.slug || 'Unnamed';
106
+ const slug = b.slug || b.id || '';
107
+ console.log(` ${i + 1}. ${name} (${slug})`);
108
+ });
109
+ console.log('');
110
+
111
+ const answer = await promptUser(' Which business to pull? (number or slug, or "skip"): ');
112
+
113
+ if (!answer || answer.toLowerCase() === 'skip') {
114
+ console.log(' Skipped. You can pull a business later with: atris pull <slug>');
115
+ console.log('');
116
+ printFinished();
117
+ return;
118
+ }
119
+
120
+ // Resolve selection — try number first, then slug match
121
+ let selected = null;
122
+ const num = parseInt(answer, 10);
123
+ if (!isNaN(num) && num >= 1 && num <= businesses.length) {
124
+ selected = businesses[num - 1];
125
+ } else {
126
+ // Try slug or name match
127
+ const q = answer.toLowerCase();
128
+ selected = businesses.find(b => (b.slug || '').toLowerCase() === q)
129
+ || businesses.find(b => (b.name || '').toLowerCase() === q)
130
+ || businesses.find(b => (b.slug || '').toLowerCase().includes(q))
131
+ || businesses.find(b => (b.name || '').toLowerCase().includes(q));
132
+ }
133
+
134
+ if (!selected) {
135
+ console.error(`\n Could not find a business matching "${answer}".`);
136
+ console.log(' Run "atris pull <slug>" to pull manually.');
137
+ console.log('');
138
+ printFinished();
139
+ return;
140
+ }
141
+
142
+ const slug = selected.slug || selected.id;
143
+ console.log(`\n [4/4] Pulling "${selected.name || slug}"...`);
144
+
145
+ try {
146
+ const { pullAtris } = require('./pull');
147
+ // Set the arg so pullAtris picks it up
148
+ const originalArgv = process.argv.slice();
149
+ process.argv[3] = slug;
150
+ const originalExit = process.exit;
151
+ process.exit = (code) => {
152
+ if (code === 0) return;
153
+ originalExit(code);
154
+ };
155
+ try {
156
+ await pullAtris();
157
+ } finally {
158
+ process.exit = originalExit;
159
+ process.argv = originalArgv;
160
+ }
161
+ console.log(` Pulled "${selected.name || slug}" ... OK`);
162
+ } catch (err) {
163
+ console.error(`\n Pull failed: ${err.message || err}`);
164
+ console.log(` You can try again with: atris pull ${slug}`);
165
+ }
166
+
167
+ console.log('');
168
+ printFinished();
169
+ }
170
+
171
+ function printFinished() {
172
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
173
+ console.log(' You\'re all set! Run `atris activate` to start.');
174
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
175
+ console.log('');
176
+ }
177
+
178
+ module.exports = { setupAtris };
@@ -0,0 +1,249 @@
1
+ const { loadCredentials } = require('../utils/auth');
2
+ const { apiRequestJson } = require('../utils/api');
3
+ const { loadBusinesses, saveBusinesses } = require('./business');
4
+
5
+ // Junk detection patterns
6
+ const JUNK_PATTERNS = {
7
+ emptyFiles: (file) => (file.size || 0) <= 1,
8
+ versionedDuplicates: (file) => /_v\d+\.\w+$/.test(file.path),
9
+ actionQueues: (file) => /action_queue\.json$/.test(file.path),
10
+ agentOutputDumps: (file) => /^\/?(agents\/[^/]+\/output\/)/.test(file.path),
11
+ researchDumps: (file) => /^\/?(agents\/[^/]+\/research\/)/.test(file.path),
12
+ };
13
+
14
+ const JUNK_LABELS = {
15
+ emptyFiles: 'Empty files (size <= 1 byte)',
16
+ versionedDuplicates: 'Versioned duplicates (*_v1, *_v2, etc.)',
17
+ actionQueues: 'Action queue files',
18
+ agentOutputDumps: 'Agent output dumps (agents/*/output/)',
19
+ researchDumps: 'Research dumps (agents/*/research/)',
20
+ };
21
+
22
+ function formatBytes(bytes) {
23
+ if (bytes === 0) return '0 B';
24
+ const units = ['B', 'KB', 'MB', 'GB'];
25
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
26
+ return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
27
+ }
28
+
29
+ async function cleanWorkspace() {
30
+ const slug = process.argv[3];
31
+ const autoConfirm = process.argv.includes('--yes');
32
+
33
+ if (!slug || slug === '--help') {
34
+ console.log('');
35
+ console.log('Usage: atris clean-workspace <business-slug> [--yes]');
36
+ console.log('');
37
+ console.log('Analyzes a workspace for junk files and shows a cleanup report.');
38
+ console.log('Pass --yes to actually delete the detected junk.');
39
+ console.log('');
40
+ console.log('Detects:');
41
+ console.log(' - Empty files (0-1 bytes)');
42
+ console.log(' - Versioned duplicates (*_v1.md, *_v2.md, etc.)');
43
+ console.log(' - action_queue.json files');
44
+ console.log(' - Agent output dumps (agents/*/output/)');
45
+ console.log(' - Research dumps (agents/*/research/)');
46
+ console.log('');
47
+ return;
48
+ }
49
+
50
+ // Auth
51
+ const creds = loadCredentials();
52
+ if (!creds || !creds.token) {
53
+ console.error('Not logged in. Run: atris login');
54
+ process.exit(1);
55
+ }
56
+
57
+ // Resolve business
58
+ let businessId, workspaceId, businessName;
59
+ const businesses = loadBusinesses();
60
+
61
+ if (businesses[slug]) {
62
+ businessId = businesses[slug].business_id;
63
+ workspaceId = businesses[slug].workspace_id;
64
+ businessName = businesses[slug].name || slug;
65
+ } else {
66
+ const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
67
+ if (!listResult.ok) {
68
+ console.error(`Failed to fetch businesses: ${listResult.error || listResult.status}`);
69
+ process.exit(1);
70
+ }
71
+ const match = (listResult.data || []).find(
72
+ b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase()
73
+ );
74
+ if (!match) {
75
+ console.error(`Business "${slug}" not found.`);
76
+ process.exit(1);
77
+ }
78
+ businessId = match.id;
79
+ workspaceId = match.workspace_id;
80
+ businessName = match.name;
81
+
82
+ // Cache for next time
83
+ businesses[slug] = {
84
+ business_id: businessId,
85
+ workspace_id: workspaceId,
86
+ name: businessName,
87
+ slug: match.slug,
88
+ added_at: new Date().toISOString(),
89
+ };
90
+ saveBusinesses(businesses);
91
+ }
92
+
93
+ if (!workspaceId) {
94
+ console.error(`Business "${slug}" has no workspace.`);
95
+ process.exit(1);
96
+ }
97
+
98
+ // Fetch snapshot (metadata only)
99
+ console.log('');
100
+ console.log(`Scanning ${businessName}...`);
101
+
102
+ const result = await apiRequestJson(
103
+ `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=false`,
104
+ { method: 'GET', token: creds.token, timeoutMs: 60000 }
105
+ );
106
+
107
+ if (!result.ok) {
108
+ const msg = result.error || `HTTP ${result.status}`;
109
+ if (result.status === 409) {
110
+ console.error('\n Computer is sleeping. Wake it first.');
111
+ } else if (result.status === 403) {
112
+ console.error(`\n Access denied for "${slug}".`);
113
+ } else if (result.status === 404) {
114
+ console.error(`\n Business "${slug}" not found.`);
115
+ } else {
116
+ console.error(`\n Failed: ${msg}`);
117
+ }
118
+ process.exit(1);
119
+ }
120
+
121
+ const files = result.data.files || [];
122
+ if (files.length === 0) {
123
+ console.log(' Workspace is empty. Nothing to clean.');
124
+ return;
125
+ }
126
+
127
+ // Analyze workspace
128
+ const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
129
+
130
+ // Directory breakdown
131
+ const dirStats = {};
132
+ for (const file of files) {
133
+ const p = (file.path || '').replace(/^\//, '');
134
+ const topDir = p.includes('/') ? p.split('/')[0] : '(root)';
135
+ if (!dirStats[topDir]) dirStats[topDir] = { count: 0, size: 0 };
136
+ dirStats[topDir].count++;
137
+ dirStats[topDir].size += file.size || 0;
138
+ }
139
+
140
+ // Detect junk
141
+ const junkByCategory = {};
142
+ const allJunkPaths = new Set();
143
+
144
+ for (const [key, testFn] of Object.entries(JUNK_PATTERNS)) {
145
+ const matches = files.filter(testFn);
146
+ if (matches.length > 0) {
147
+ junkByCategory[key] = matches;
148
+ for (const m of matches) allJunkPaths.add(m.path);
149
+ }
150
+ }
151
+
152
+ const junkSize = files
153
+ .filter(f => allJunkPaths.has(f.path))
154
+ .reduce((sum, f) => sum + (f.size || 0), 0);
155
+
156
+ // Print report
157
+ console.log('');
158
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
159
+ console.log(` Workspace: ${businessName}`);
160
+ console.log(` Total files: ${files.length} Total size: ${formatBytes(totalSize)}`);
161
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
162
+
163
+ // Directory breakdown
164
+ console.log('');
165
+ console.log(' Files by directory:');
166
+ const sortedDirs = Object.entries(dirStats).sort((a, b) => b[1].size - a[1].size);
167
+ for (const [dir, stats] of sortedDirs) {
168
+ const pct = totalSize > 0 ? ((stats.size / totalSize) * 100).toFixed(0) : 0;
169
+ console.log(` ${dir.padEnd(30)} ${String(stats.count).padStart(5)} files ${formatBytes(stats.size).padStart(10)} (${pct}%)`);
170
+ }
171
+
172
+ // Junk report
173
+ console.log('');
174
+ if (allJunkPaths.size === 0) {
175
+ console.log(' No junk detected. Workspace is clean.');
176
+ console.log('');
177
+ return;
178
+ }
179
+
180
+ console.log(' Junk detected:');
181
+ console.log('');
182
+
183
+ for (const [key, matches] of Object.entries(junkByCategory)) {
184
+ const catSize = matches.reduce((sum, f) => sum + (f.size || 0), 0);
185
+ console.log(` ${JUNK_LABELS[key]} (${matches.length} files, ${formatBytes(catSize)})`);
186
+
187
+ // Show up to 10 example paths
188
+ const show = matches.slice(0, 10);
189
+ for (const f of show) {
190
+ console.log(` - ${(f.path || '').replace(/^\//, '')} (${formatBytes(f.size || 0)})`);
191
+ }
192
+ if (matches.length > 10) {
193
+ console.log(` ... and ${matches.length - 10} more`);
194
+ }
195
+ console.log('');
196
+ }
197
+
198
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
199
+ console.log(` Would remove: ${allJunkPaths.size} files (${formatBytes(junkSize)})`);
200
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
201
+ console.log('');
202
+
203
+ if (!autoConfirm) {
204
+ console.log(' Run with --yes to clean up:');
205
+ console.log(` atris clean-workspace ${slug} --yes`);
206
+ console.log('');
207
+ return;
208
+ }
209
+
210
+ // Delete junk by syncing empty content
211
+ console.log(' Cleaning...');
212
+
213
+ const filesToDelete = Array.from(allJunkPaths).map(p => ({ path: p, content: '' }));
214
+
215
+ // Batch in chunks of 50 to avoid huge payloads
216
+ const BATCH_SIZE = 50;
217
+ let deleted = 0;
218
+
219
+ for (let i = 0; i < filesToDelete.length; i += BATCH_SIZE) {
220
+ const batch = filesToDelete.slice(i, i + BATCH_SIZE);
221
+
222
+ const syncResult = await apiRequestJson(
223
+ `/businesses/${businessId}/workspaces/${workspaceId}/sync`,
224
+ {
225
+ method: 'POST',
226
+ token: creds.token,
227
+ body: { files: batch },
228
+ headers: { 'X-Atris-Actor-Source': 'cli' },
229
+ }
230
+ );
231
+
232
+ if (!syncResult.ok) {
233
+ const msg = syncResult.error || `HTTP ${syncResult.status}`;
234
+ console.error(`\n Cleanup failed at batch ${Math.floor(i / BATCH_SIZE) + 1}: ${msg}`);
235
+ process.exit(1);
236
+ }
237
+
238
+ deleted += batch.length;
239
+ if (filesToDelete.length > BATCH_SIZE) {
240
+ console.log(` ${deleted}/${filesToDelete.length} files processed...`);
241
+ }
242
+ }
243
+
244
+ console.log('');
245
+ console.log(` Done. Removed ${deleted} junk files (${formatBytes(junkSize)}).`);
246
+ console.log('');
247
+ }
248
+
249
+ module.exports = { cleanWorkspace };
package/lib/manifest.js CHANGED
@@ -1,3 +1,4 @@
1
+ const crypto = require('crypto');
1
2
  const fs = require('fs');
2
3
  const path = require('path');
3
4
  const os = require('os');
@@ -82,9 +83,10 @@ function computeLocalHashes(localDir) {
82
83
  } else if (entry.isFile()) {
83
84
  const relPath = '/' + path.relative(localDir, fullPath);
84
85
  try {
85
- const content = fs.readFileSync(fullPath, 'utf8');
86
- const hash = computeFileHash(content);
87
- files[relPath] = { hash, size: Buffer.byteLength(content) };
86
+ // Hash raw bytes to match warm runner's _hash_bytes(data)
87
+ const rawBytes = fs.readFileSync(fullPath);
88
+ const hash = crypto.createHash('sha256').update(rawBytes).digest('hex');
89
+ files[relPath] = { hash, size: rawBytes.length };
88
90
  } catch {
89
91
  // skip binary or unreadable
90
92
  }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Section-level three-way merge for structured markdown files.
3
+ *
4
+ * Parses markdown into sections (split on ## headers + YAML frontmatter).
5
+ * Merges non-conflicting section changes. Flags same-section conflicts.
6
+ *
7
+ * This is what makes us better than git for context files.
8
+ * Git merges by line. We merge by section.
9
+ */
10
+
11
+ /**
12
+ * Parse a markdown document into sections.
13
+ * Returns: { __frontmatter__: string, __header__: string, sections: [{name, content}] }
14
+ */
15
+ function parseSections(content) {
16
+ if (!content) return { frontmatter: '', header: '', sections: [] };
17
+
18
+ const lines = content.split('\n');
19
+ let frontmatter = '';
20
+ let header = '';
21
+ const sections = [];
22
+ let current = null;
23
+ let inFrontmatter = false;
24
+ let frontmatterDone = false;
25
+ let headerLines = [];
26
+
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const line = lines[i];
29
+
30
+ // YAML frontmatter
31
+ if (i === 0 && line.trim() === '---') {
32
+ inFrontmatter = true;
33
+ headerLines.push(line);
34
+ continue;
35
+ }
36
+ if (inFrontmatter) {
37
+ headerLines.push(line);
38
+ if (line.trim() === '---') {
39
+ inFrontmatter = false;
40
+ frontmatterDone = true;
41
+ frontmatter = headerLines.join('\n');
42
+ headerLines = [];
43
+ }
44
+ continue;
45
+ }
46
+
47
+ // Section headers
48
+ if (line.startsWith('## ')) {
49
+ // Save previous section or header
50
+ if (current) {
51
+ sections.push(current);
52
+ } else if (headerLines.length > 0) {
53
+ header = headerLines.join('\n');
54
+ }
55
+ current = { name: line.substring(3).trim(), content: line };
56
+ continue;
57
+ }
58
+
59
+ // Content belongs to current section or header
60
+ if (current) {
61
+ current.content += '\n' + line;
62
+ } else {
63
+ headerLines.push(line);
64
+ }
65
+ }
66
+
67
+ // Save last section or header
68
+ if (current) {
69
+ sections.push(current);
70
+ } else if (headerLines.length > 0 && !header) {
71
+ header = headerLines.join('\n');
72
+ }
73
+
74
+ return { frontmatter, header, sections };
75
+ }
76
+
77
+ /**
78
+ * Reconstruct a markdown document from parsed sections.
79
+ */
80
+ function reconstructDocument(parsed) {
81
+ const parts = [];
82
+ if (parsed.frontmatter) parts.push(parsed.frontmatter);
83
+ if (parsed.header) parts.push(parsed.header);
84
+ for (const section of parsed.sections) {
85
+ parts.push(section.content);
86
+ }
87
+ return parts.join('\n');
88
+ }
89
+
90
+ /**
91
+ * Three-way section merge.
92
+ *
93
+ * @param {string} base - Common ancestor content
94
+ * @param {string} local - Your version
95
+ * @param {string} remote - Their version
96
+ * @returns {{ merged: string|null, conflicts: [{section, local, remote}] }}
97
+ *
98
+ * If merged is non-null, the merge succeeded (conflicts array is empty).
99
+ * If merged is null, there are conflicts that need manual resolution.
100
+ */
101
+ function sectionMerge(base, local, remote) {
102
+ const baseParsed = parseSections(base);
103
+ const localParsed = parseSections(local);
104
+ const remoteParsed = parseSections(remote);
105
+
106
+ const conflicts = [];
107
+
108
+ // Merge frontmatter (field-by-field if both changed, otherwise take the changed one)
109
+ let mergedFrontmatter = baseParsed.frontmatter;
110
+ if (localParsed.frontmatter !== baseParsed.frontmatter && remoteParsed.frontmatter === baseParsed.frontmatter) {
111
+ mergedFrontmatter = localParsed.frontmatter;
112
+ } else if (remoteParsed.frontmatter !== baseParsed.frontmatter && localParsed.frontmatter === baseParsed.frontmatter) {
113
+ mergedFrontmatter = remoteParsed.frontmatter;
114
+ } else if (localParsed.frontmatter !== remoteParsed.frontmatter && localParsed.frontmatter !== baseParsed.frontmatter) {
115
+ conflicts.push({ section: 'frontmatter', local: localParsed.frontmatter, remote: remoteParsed.frontmatter });
116
+ }
117
+
118
+ // Merge header
119
+ let mergedHeader = baseParsed.header;
120
+ if (localParsed.header !== baseParsed.header && remoteParsed.header === baseParsed.header) {
121
+ mergedHeader = localParsed.header;
122
+ } else if (remoteParsed.header !== baseParsed.header && localParsed.header === baseParsed.header) {
123
+ mergedHeader = remoteParsed.header;
124
+ } else if (localParsed.header !== remoteParsed.header && localParsed.header !== baseParsed.header) {
125
+ conflicts.push({ section: 'header', local: localParsed.header, remote: remoteParsed.header });
126
+ }
127
+
128
+ // Build section maps
129
+ const baseMap = {};
130
+ for (const s of baseParsed.sections) baseMap[s.name] = s.content;
131
+ const localMap = {};
132
+ for (const s of localParsed.sections) localMap[s.name] = s.content;
133
+ const remoteMap = {};
134
+ for (const s of remoteParsed.sections) remoteMap[s.name] = s.content;
135
+
136
+ // Get all section names preserving order (base order, then new sections)
137
+ const allNames = [];
138
+ const seen = new Set();
139
+ for (const s of baseParsed.sections) { allNames.push(s.name); seen.add(s.name); }
140
+ for (const s of localParsed.sections) { if (!seen.has(s.name)) { allNames.push(s.name); seen.add(s.name); } }
141
+ for (const s of remoteParsed.sections) { if (!seen.has(s.name)) { allNames.push(s.name); seen.add(s.name); } }
142
+
143
+ // Merge each section
144
+ const mergedSections = [];
145
+ for (const name of allNames) {
146
+ const b = baseMap[name] || null;
147
+ const l = localMap[name] || null;
148
+ const r = remoteMap[name] || null;
149
+
150
+ if (l === r) {
151
+ // Both same — take either (or null = both deleted)
152
+ if (l !== null) mergedSections.push({ name, content: l });
153
+ continue;
154
+ }
155
+
156
+ if (b === null) {
157
+ // New section — exists in one or both
158
+ if (l && !r) { mergedSections.push({ name, content: l }); continue; }
159
+ if (r && !l) { mergedSections.push({ name, content: r }); continue; }
160
+ // Both added same-named section with different content
161
+ conflicts.push({ section: name, local: l, remote: r });
162
+ mergedSections.push({ name, content: l }); // default to local
163
+ continue;
164
+ }
165
+
166
+ const localChanged = l !== b;
167
+ const remoteChanged = r !== b;
168
+
169
+ if (!localChanged && remoteChanged) {
170
+ if (r !== null) mergedSections.push({ name, content: r });
171
+ // else: remote deleted it, local didn't change → accept deletion
172
+ } else if (localChanged && !remoteChanged) {
173
+ if (l !== null) mergedSections.push({ name, content: l });
174
+ // else: local deleted it, remote didn't change → accept deletion
175
+ } else {
176
+ // Both changed the same section → conflict
177
+ conflicts.push({ section: name, local: l, remote: r });
178
+ if (l !== null) mergedSections.push({ name, content: l }); // default to local
179
+ }
180
+ }
181
+
182
+ if (conflicts.length > 0) {
183
+ return { merged: null, conflicts };
184
+ }
185
+
186
+ // Reconstruct
187
+ const merged = reconstructDocument({
188
+ frontmatter: mergedFrontmatter,
189
+ header: mergedHeader,
190
+ sections: mergedSections,
191
+ });
192
+
193
+ return { merged, conflicts: [] };
194
+ }
195
+
196
+ module.exports = { parseSections, reconstructDocument, sectionMerge };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "2.6.1",
3
+ "version": "2.6.2",
4
4
  "description": "atrisDev (atris dev) - CLI for AI coding agents. Works with Claude Code, Cursor, Windsurf. Make any codebase AI-navigable.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
package/utils/api.js CHANGED
@@ -41,7 +41,7 @@ function httpRequest(urlString, options) {
41
41
  const parsed = new URL(urlString);
42
42
  const isHttps = parsed.protocol === 'https:';
43
43
  const transport = isHttps ? https : http;
44
- const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 10000;
44
+ const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 30000;
45
45
 
46
46
  const requestOptions = {
47
47
  method: options.method || 'GET',
@@ -52,6 +52,13 @@ function httpRequest(urlString, options) {
52
52
  };
53
53
 
54
54
  const req = transport.request(requestOptions, (res) => {
55
+ // Follow redirects (301, 302, 307, 308)
56
+ if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
57
+ const redirectUrl = new URL(res.headers.location, urlString).toString();
58
+ resolve(httpRequest(redirectUrl, options));
59
+ return;
60
+ }
61
+
55
62
  const chunks = [];
56
63
  res.on('data', (chunk) => chunks.push(chunk));
57
64
  res.on('end', () => {
@@ -111,6 +118,7 @@ async function apiRequestJson(pathname, options = {}) {
111
118
  method: options.method || 'GET',
112
119
  headers,
113
120
  body: bodyPayload,
121
+ timeoutMs: options.timeoutMs,
114
122
  });
115
123
 
116
124
  const text = result.body.toString('utf8');
@@ -4,8 +4,9 @@ const path = require('path');
4
4
  const os = require('os');
5
5
 
6
6
  const PACKAGE_NAME = 'atris';
7
- const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
8
- const CACHE_FILE = path.join(os.homedir(), '.atris-update-check.json');
7
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
8
+ const ATRIS_DIR = path.join(os.homedir(), '.atris');
9
+ const CACHE_FILE = path.join(ATRIS_DIR, '.update-check');
9
10
 
10
11
  function getInstalledVersion() {
11
12
  try {
@@ -34,6 +35,10 @@ function getCacheData() {
34
35
 
35
36
  function saveCacheData(latestVersion) {
36
37
  try {
38
+ // Ensure ~/.atris/ exists
39
+ if (!fs.existsSync(ATRIS_DIR)) {
40
+ fs.mkdirSync(ATRIS_DIR, { recursive: true });
41
+ }
37
42
  const data = {
38
43
  lastCheck: new Date().toISOString(),
39
44
  latestVersion: latestVersion,
@@ -164,15 +169,10 @@ async function checkForUpdates(force = false) {
164
169
  function showUpdateNotification(updateInfo) {
165
170
  if (!updateInfo || !updateInfo.needsUpdate) return;
166
171
 
167
- console.log('');
168
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
169
- console.log(`📦 Update available: ${updateInfo.installed} → ${updateInfo.latest}`);
170
- console.log(` Run: npm update -g atris`);
171
- if (updateInfo.fromCache) {
172
- console.log(` (checking npm registry...)`);
173
- }
174
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
175
- console.log('');
172
+ // Single yellow warning line — non-intrusive
173
+ const yellow = '\x1b[33m';
174
+ const reset = '\x1b[0m';
175
+ console.log(`${yellow}Update available: ${updateInfo.installed} → ${updateInfo.latest}. Run: npm install -g atris${reset}`);
176
176
  }
177
177
 
178
178
  function autoUpdate(updateInfo) {