atris 3.68.7 → 3.68.8

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.
@@ -10904,6 +10904,29 @@ async function memberRoom(...args) {
10904
10904
  return room;
10905
10905
  }
10906
10906
 
10907
+ // --- EXPORT subcommand ---
10908
+
10909
+ function memberExport(...args) {
10910
+ const { exportMembers, summaryLine } = require('../lib/member-export');
10911
+ const dryRun = args.includes('--dry-run');
10912
+ const root = process.cwd();
10913
+ if (!fs.existsSync(path.join(root, 'atris', 'team'))) {
10914
+ console.error('No atris/team folder here, so there are no members to export.');
10915
+ process.exitCode = 1;
10916
+ return null;
10917
+ }
10918
+ const result = exportMembers(root, { dryRun });
10919
+ if (args.includes('--json')) {
10920
+ console.log(JSON.stringify({ dry_run: dryRun, ...result }, null, 2));
10921
+ return result;
10922
+ }
10923
+ for (const file of result.written) console.log(`${dryRun ? 'Would write' : '✓ Wrote'} ${file}`);
10924
+ for (const file of result.removed) console.log(`${dryRun ? 'Would remove' : '✓ Removed'} ${file}`);
10925
+ for (const file of result.kept) console.log(`Left alone ${file} (hand-written, not made by export)`);
10926
+ console.log(summaryLine(result, { dryRun }));
10927
+ return result;
10928
+ }
10929
+
10907
10930
  async function memberCommand(subcommand, ...args) {
10908
10931
  // Subcommands that take a member name as args[0] otherwise treat `--help` as
10909
10932
  // a name and error with "Member '--help' not found". `create`/`new` handle
@@ -11013,6 +11036,8 @@ async function memberCommand(subcommand, ...args) {
11013
11036
  return memberObjectiveGeneratorCommand(args[0], ...args.slice(1));
11014
11037
  case 'generalist':
11015
11038
  return memberGeneralistCommand(args[0], ...args.slice(1));
11039
+ case 'export':
11040
+ return memberExport(...args);
11016
11041
  case 'archive':
11017
11042
  return memberArchive(args[0]);
11018
11043
  case 'purge-archived':
@@ -11047,6 +11072,7 @@ async function memberCommand(subcommand, ...args) {
11047
11072
  console.log(' install <slug> [--update] Install missing files, or refresh template skills');
11048
11073
  console.log(' chat <name> "..." Send a message to this member\'s desk in Atris Desktop');
11049
11074
  console.log(' list [--json] Show all team members');
11075
+ console.log(' export [--dry-run] Write each member as a subagent for Claude Code, Cursor, and Codex');
11050
11076
  console.log(' activate <name> Symlink member skills, show context and permissions');
11051
11077
  console.log(' upgrade <name> Convert flat file (name.md) to directory format');
11052
11078
  console.log(' push <name> Push a local team member to the cloud');
package/commands/sync.js CHANGED
@@ -437,6 +437,18 @@ function syncBusinessCanonical(targetRoot, bizMeta, options = {}) {
437
437
  });
438
438
  }
439
439
 
440
+ // Team members follow every written update, business or legacy, into Claude
441
+ // Code, Cursor, and Codex as subagents. An export failure never fails the update.
442
+ function exportTeamAfterUpdate(root, { dryRun = false } = {}) {
443
+ if (dryRun || !fs.existsSync(path.join(root, 'atris', 'team'))) return;
444
+ try {
445
+ const { exportMembers, summaryLine } = require('../lib/member-export');
446
+ console.log(`✓ Team: ${summaryLine(exportMembers(root))}`);
447
+ } catch (err) {
448
+ console.log(`Team export skipped: ${err.message || err}`);
449
+ }
450
+ }
451
+
440
452
  function syncAtris(options = {}) {
441
453
  const explicitDryRun = options.dryRun != null ? options.dryRun : process.argv.includes('--dry-run');
442
454
  const yes = options.yes != null
@@ -455,6 +467,7 @@ function syncAtris(options = {}) {
455
467
  dryRun,
456
468
  force,
457
469
  });
470
+ exportTeamAfterUpdate(process.cwd(), { dryRun });
458
471
  if (!yes) {
459
472
  console.log('\nPlan only. Re-run with --yes to write these files.');
460
473
  if (!explicitDryRun && isNonInteractive()) process.exit(2);
@@ -695,6 +708,8 @@ Key behaviors:
695
708
  console.log(`\n✓ Updated ${updated} file(s), ${skipped} unchanged`);
696
709
  console.log('\nRun your AI agent again to use the latest specs and agent templates.');
697
710
  }
711
+
712
+ exportTeamAfterUpdate(process.cwd(), { dryRun });
698
713
  }
699
714
 
700
715
  /**
@@ -0,0 +1,240 @@
1
+ 'use strict';
2
+
3
+ // `atris member export`: each active atris/team/<member>/MEMBER.md becomes a
4
+ // native subagent file for the coding tools that read one.
5
+ //
6
+ // claude .claude/agents/<name>.md markdown, YAML frontmatter (name, description, model)
7
+ // cursor .cursor/agents/<name>.md same markdown shape; cursor-agent globs **/.cursor/agents/**/*.md
8
+ // codex .codex/agents/<name>.toml agent role file (name, description, developer_instructions);
9
+ // codex 0.162 discovers roles in .codex/agents
10
+ //
11
+ // Every file carries GENERATED_MARK. Export only overwrites or removes files
12
+ // that carry it, so a hand-written agent with the same name is left alone.
13
+ // Parked members are skipped, and their old generated files are removed.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { isParkedText } = require('./member-park');
18
+
19
+ const GENERATED_MARK = 'generated by atris member export';
20
+ const TARGETS = ['claude', 'cursor', 'codex'];
21
+ const SKIP_DIRS = new Set(['_template', '_archive', '_archived']);
22
+
23
+ function frontmatterOf(text) {
24
+ const match = String(text || '').replace(/^/, '').match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
25
+ if (!match) return { fields: {}, body: String(text || '') };
26
+ const fields = {};
27
+ let listKey = null;
28
+ for (const line of match[1].split(/\r?\n/)) {
29
+ const item = line.match(/^\s+-\s+(.+)$/);
30
+ if (item && listKey) {
31
+ fields[listKey].push(item[1].trim().replace(/^["']|["']$/g, ''));
32
+ continue;
33
+ }
34
+ const kv = line.match(/^([a-zA-Z_-]+):\s*(.*)$/);
35
+ if (!kv) continue;
36
+ const value = kv[2].replace(/\s+#.*$/, '').trim();
37
+ if (value === '') {
38
+ fields[kv[1]] = [];
39
+ listKey = kv[1];
40
+ } else if (value.startsWith('[') && value.endsWith(']')) {
41
+ fields[kv[1]] = value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
42
+ listKey = null;
43
+ } else {
44
+ fields[kv[1]] = value.replace(/^["']|["']$/g, '');
45
+ listKey = null;
46
+ }
47
+ }
48
+ return { fields, body: String(text).replace(/^/, '').slice(match[0].length) };
49
+ }
50
+
51
+ function scalar(value) {
52
+ return typeof value === 'string' ? value.trim() : '';
53
+ }
54
+
55
+ function listOf(value) {
56
+ return Array.isArray(value) ? value.filter((v) => typeof v === 'string' && v.trim()) : [];
57
+ }
58
+
59
+ // Skills named in frontmatter plus any skills/<name>/SKILL.md on disk.
60
+ function memberSkills(dir, fields) {
61
+ const names = new Set(listOf(fields.skills));
62
+ const skillsDir = path.join(dir, 'skills');
63
+ try {
64
+ for (const entry of fs.readdirSync(skillsDir)) {
65
+ if (fs.existsSync(path.join(skillsDir, entry, 'SKILL.md'))) names.add(entry);
66
+ }
67
+ } catch {}
68
+ return [...names].sort();
69
+ }
70
+
71
+ function readMembers(root) {
72
+ const teamDir = path.join(root, 'atris', 'team');
73
+ let entries = [];
74
+ try {
75
+ entries = fs.readdirSync(teamDir).sort();
76
+ } catch {
77
+ return { members: [], parked: [] };
78
+ }
79
+ const members = [];
80
+ const parked = [];
81
+ for (const entry of entries) {
82
+ if (SKIP_DIRS.has(entry) || entry.startsWith('.')) continue;
83
+ const dir = path.join(teamDir, entry);
84
+ const file = path.join(dir, 'MEMBER.md');
85
+ let text;
86
+ try {
87
+ text = fs.readFileSync(file, 'utf8');
88
+ } catch {
89
+ continue;
90
+ }
91
+ if (isParkedText(text)) {
92
+ parked.push(entry);
93
+ continue;
94
+ }
95
+ const { fields, body } = frontmatterOf(text);
96
+ members.push({
97
+ slug: entry,
98
+ role: scalar(fields.role),
99
+ description: scalar(fields.description),
100
+ model: scalar(fields.model),
101
+ skills: memberSkills(dir, fields),
102
+ body: body.trim(),
103
+ });
104
+ }
105
+ return { members, parked };
106
+ }
107
+
108
+ // Claude Code wants lowercase letters, digits, and hyphens; codex allows
109
+ // letters, digits, spaces, hyphens, and underscores. One safe form fits both.
110
+ function agentName(slug) {
111
+ return String(slug).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'member';
112
+ }
113
+
114
+ // Written for the tool's router: what the member does, then when to hand it work.
115
+ function agentDescription(member) {
116
+ const what = member.description.replace(/\s+/g, ' ').trim();
117
+ const role = member.role || member.slug;
118
+ const lead = what ? (/[.!?]$/.test(what) ? what : `${what}.`) : `The ${role} on this project's Atris team.`;
119
+ return `${lead} Use this agent when a task fits the ${role} role on this project's Atris team.`;
120
+ }
121
+
122
+ function instructions(member) {
123
+ const lines = [
124
+ `You are ${member.slug}, the ${member.role || member.slug} on this project's Atris team.`,
125
+ `Your full card is atris/team/${member.slug}/MEMBER.md; read it when this summary is not enough.`,
126
+ '',
127
+ ];
128
+ if (member.body) lines.push(member.body, '');
129
+ if (member.skills.length) {
130
+ lines.push('## Skills', '');
131
+ for (const skill of member.skills) {
132
+ lines.push(`- ${skill}: atris/team/${member.slug}/skills/${skill}/SKILL.md`);
133
+ }
134
+ lines.push('');
135
+ }
136
+ return lines.join('\n').trimEnd() + '\n';
137
+ }
138
+
139
+ function markdownAgent(member, { withModel }) {
140
+ const source = `atris/team/${member.slug}/MEMBER.md`;
141
+ const front = [
142
+ '---',
143
+ `# ${GENERATED_MARK} from ${source}. edit MEMBER.md; this file gets overwritten.`,
144
+ `name: ${agentName(member.slug)}`,
145
+ `description: ${JSON.stringify(agentDescription(member))}`,
146
+ ];
147
+ if (withModel && member.model) front.push(`model: ${JSON.stringify(member.model)}`);
148
+ front.push('---', '', '');
149
+ return front.join('\n') + instructions(member);
150
+ }
151
+
152
+ // TOML basic strings take the same escapes JSON.stringify emits.
153
+ function codexAgent(member) {
154
+ const source = `atris/team/${member.slug}/MEMBER.md`;
155
+ return [
156
+ `# ${GENERATED_MARK} from ${source}. edit MEMBER.md; this file gets overwritten.`,
157
+ `name = ${JSON.stringify(agentName(member.slug))}`,
158
+ `description = ${JSON.stringify(agentDescription(member))}`,
159
+ `developer_instructions = ${JSON.stringify(instructions(member))}`,
160
+ '',
161
+ ].join('\n');
162
+ }
163
+
164
+ const FORMATS = {
165
+ claude: { dir: ['.claude', 'agents'], ext: '.md', render: (m) => markdownAgent(m, { withModel: true }) },
166
+ cursor: { dir: ['.cursor', 'agents'], ext: '.md', render: (m) => markdownAgent(m, { withModel: false }) },
167
+ codex: { dir: ['.codex', 'agents'], ext: '.toml', render: codexAgent },
168
+ };
169
+
170
+ function isGenerated(file) {
171
+ try {
172
+ return fs.readFileSync(file, 'utf8').slice(0, 400).includes(GENERATED_MARK);
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ function exportMembers(root, { targets = TARGETS, dryRun = false } = {}) {
179
+ const { members, parked } = readMembers(root);
180
+ const result = { members: members.map((m) => m.slug), parked, written: [], unchanged: [], removed: [], kept: [] };
181
+ if (!fs.existsSync(path.join(root, 'atris', 'team'))) return result;
182
+
183
+ for (const target of targets) {
184
+ const format = FORMATS[target];
185
+ if (!format) continue;
186
+ const dir = path.join(root, ...format.dir);
187
+ const wanted = new Set();
188
+ for (const member of members) {
189
+ const file = path.join(dir, agentName(member.slug) + format.ext);
190
+ const rel = path.relative(root, file);
191
+ wanted.add(file);
192
+ const next = format.render(member);
193
+ if (fs.existsSync(file)) {
194
+ if (!isGenerated(file)) {
195
+ result.kept.push(rel);
196
+ continue;
197
+ }
198
+ if (fs.readFileSync(file, 'utf8') === next) {
199
+ result.unchanged.push(rel);
200
+ continue;
201
+ }
202
+ }
203
+ if (!dryRun) {
204
+ fs.mkdirSync(dir, { recursive: true });
205
+ fs.writeFileSync(file, next, 'utf8');
206
+ }
207
+ result.written.push(rel);
208
+ }
209
+ // A member that was parked or removed leaves no stale generated file behind.
210
+ let existing = [];
211
+ try {
212
+ existing = fs.readdirSync(dir);
213
+ } catch {}
214
+ for (const entry of existing) {
215
+ const file = path.join(dir, entry);
216
+ if (!entry.endsWith(format.ext) || wanted.has(file) || !isGenerated(file)) continue;
217
+ if (!dryRun) fs.unlinkSync(file);
218
+ result.removed.push(path.relative(root, file));
219
+ }
220
+ }
221
+ return result;
222
+ }
223
+
224
+ function summaryLine(result, { dryRun = false } = {}) {
225
+ const count = result.members.length;
226
+ const parts = [`${count} ${count === 1 ? 'member' : 'members'} exported to claude code, cursor, and codex`];
227
+ if (result.written.length) parts.push(`${result.written.length} ${dryRun ? 'would change' : 'files written'}`);
228
+ if (result.removed.length) parts.push(`${result.removed.length} old ${dryRun ? 'would be removed' : 'removed'}`);
229
+ if (!result.written.length && !result.removed.length) parts.push('already up to date');
230
+ if (result.parked.length) parts.push(`${result.parked.length} parked skipped`);
231
+ return parts.join(', ');
232
+ }
233
+
234
+ module.exports = {
235
+ GENERATED_MARK,
236
+ agentName,
237
+ exportMembers,
238
+ frontmatterOf,
239
+ summaryLine,
240
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.68.7",
3
+ "version": "3.68.8",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {