a2acalling 0.6.4 → 0.6.6

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/cli.js CHANGED
@@ -12,11 +12,42 @@
12
12
  * a2a ping <url> Ping an invite URL
13
13
  * a2a gui Open the local dashboard GUI in a browser
14
14
  * a2a setup Auto setup (gateway-aware dashboard install)
15
+ * a2a uninstall Stop server and remove local A2A config
15
16
  */
16
17
 
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+ const readline = require('readline');
22
+ const { spawn } = require('child_process');
17
23
  const { TokenStore } = require('../src/lib/tokens');
18
24
  const { A2AClient } = require('../src/lib/client');
19
25
 
26
+ const CONFIG_DIR = process.env.A2A_CONFIG_DIR || process.env.OPENCLAW_CONFIG_DIR || path.join(os.homedir(), '.config', 'openclaw');
27
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'a2a-config.json');
28
+ const ONBOARDING_EXEMPT = new Set([
29
+ 'quickstart',
30
+ 'help',
31
+ 'version',
32
+ 'update',
33
+ 'uninstall',
34
+ 'onboard',
35
+ 'gui',
36
+ 'dashboard',
37
+ 'server',
38
+ 'setup',
39
+ 'install'
40
+ ]);
41
+
42
+ function isOnboarded() {
43
+ try {
44
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
45
+ return config.onboarding?.version === 2 && config.onboarding?.step === 'complete';
46
+ } catch (err) {
47
+ return false;
48
+ }
49
+ }
50
+
20
51
  // Lazy load conversation store (requires better-sqlite3)
21
52
  let convStore = null;
22
53
  function getConvStore() {
@@ -40,20 +71,20 @@ function getConvStore() {
40
71
 
41
72
  const store = new TokenStore();
42
73
 
43
- // Check onboarding status — warns but does not block
44
- function checkOnboarding(commandName) {
45
- try {
46
- const { A2AConfig } = require('../src/lib/config');
47
- const config = new A2AConfig();
48
- if (!config.isOnboarded()) {
49
- console.warn('\n\u26a0\ufe0f A2A onboarding not complete.');
50
- console.warn(' Run "a2a quickstart" to complete deterministic onboarding.');
51
- console.warn(' Without onboarding, invites may use default topics/goals and remote dashboard access may not be configured.\n');
52
- return false;
53
- }
54
- return true;
55
- } catch (e) {
56
- return true; // Don't block if config is broken
74
+ function enforceOnboarding(command) {
75
+ if (ONBOARDING_EXEMPT.has(command)) {
76
+ return;
77
+ }
78
+
79
+ if (!isOnboarded()) {
80
+ console.log('\n⚠️ A2A not configured yet.');
81
+ console.log('');
82
+ console.log('Run this first:');
83
+ console.log(' a2a quickstart --hostname YOUR_DOMAIN:PORT');
84
+ console.log('');
85
+ console.log('Example:');
86
+ console.log(' a2a quickstart --hostname myserver.com:3001');
87
+ process.exit(1);
57
88
  }
58
89
  }
59
90
 
@@ -71,8 +102,6 @@ function formatTimeAgo(date) {
71
102
  }
72
103
 
73
104
  function openInBrowser(url) {
74
- const { spawn } = require('child_process');
75
-
76
105
  const platform = process.platform;
77
106
  let cmd = null;
78
107
  let args = [];
@@ -162,6 +191,17 @@ function parseArgs(argv) {
162
191
  return args;
163
192
  }
164
193
 
194
+ async function promptYesNo(question) {
195
+ return await new Promise(resolve => {
196
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
197
+ rl.question(question, (answer) => {
198
+ rl.close();
199
+ const normalized = String(answer || '').trim().toLowerCase();
200
+ resolve(normalized === 'y' || normalized === 'yes');
201
+ });
202
+ });
203
+ }
204
+
165
205
  async function resolveInviteHostname() {
166
206
  const { resolveInviteHost } = require('../src/lib/invite-host');
167
207
 
@@ -184,7 +224,6 @@ async function resolveInviteHostname() {
184
224
  // Commands
185
225
  const commands = {
186
226
  create: async (args) => {
187
- checkOnboarding('create');
188
227
  // Parse max-calls: number, 'unlimited', or default (unlimited)
189
228
  let maxCalls = null; // Default: unlimited
190
229
  if (args.flags['max-calls']) {
@@ -753,7 +792,6 @@ https://github.com/onthegonow/a2a_calling`;
753
792
  },
754
793
 
755
794
  call: async (args) => {
756
- checkOnboarding('call');
757
795
  let target = args._[1];
758
796
  const message = args._.slice(2).join(' ') || args.flags.message || args.flags.m;
759
797
 
@@ -902,495 +940,705 @@ https://github.com/onthegonow/a2a_calling`;
902
940
 
903
941
  quickstart: async (args) => {
904
942
  const http = require('http');
905
- const https = require('https');
906
943
  const { A2AConfig } = require('../src/lib/config');
907
- const disc = require('../src/lib/disclosure');
944
+ const { isPortListening } = require('../src/lib/port-scanner');
945
+ const {
946
+ readContextFiles,
947
+ generateDefaultManifest,
948
+ saveManifest
949
+ } = require('../src/lib/disclosure');
908
950
  const {
909
951
  normalizeHostInput,
910
952
  splitHostPort,
911
953
  isLocalOrUnroutableHost
912
954
  } = require('../src/lib/invite-host');
913
- const { getExternalIp } = require('../src/lib/external-ip');
914
- const { CallbookStore } = require('../src/lib/callbook');
915
- const { isPortListening, tryBindPort } = require('../src/lib/port-scanner');
916
955
 
917
- const workspaceDir = process.env.A2A_WORKSPACE || process.cwd();
918
956
  const config = new A2AConfig();
957
+ const workspaceDir = process.env.A2A_WORKSPACE || process.cwd();
919
958
 
920
959
  if (args.flags.force) {
921
960
  config.resetOnboarding();
922
961
  }
923
962
 
924
- const backendPort = (() => {
925
- const raw = args.flags.port || process.env.A2A_PORT || process.env.PORT || 3001;
926
- const n = Number.parseInt(String(raw), 10);
927
- return (Number.isFinite(n) && n > 0 && n <= 65535) ? n : 3001;
928
- })();
929
-
930
- function looksLikePong(body) {
931
- try {
932
- const parsed = JSON.parse(String(body || ''));
933
- if (parsed && typeof parsed === 'object' && parsed.pong === true) return true;
934
- } catch (err) {
935
- // ignore
963
+ function parsePort(raw, fallback) {
964
+ const parsed = Number.parseInt(String(raw || '').trim(), 10);
965
+ if (Number.isFinite(parsed) && parsed > 0 && parsed <= 65535) {
966
+ return parsed;
936
967
  }
937
- return String(body || '').includes('"pong":true') || String(body || '').includes('"pong": true');
968
+ return fallback;
938
969
  }
939
970
 
940
- function fetchUrlText(url, timeoutMs = 5000) {
941
- return new Promise((resolve, reject) => {
942
- let parsed;
943
- try {
944
- parsed = new URL(url);
945
- } catch (err) {
946
- reject(new Error('invalid_url'));
947
- return;
971
+ function uniqueNonEmpty(values, limit = 80) {
972
+ const normalizeValue = (value) => {
973
+ if (typeof value === 'string') {
974
+ return String(value || '').trim();
948
975
  }
949
- const client = parsed.protocol === 'https:' ? https : http;
950
- const req = client.request({
951
- protocol: parsed.protocol,
952
- hostname: parsed.hostname,
953
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
954
- method: 'GET',
955
- path: parsed.pathname + parsed.search,
956
- headers: {
957
- 'User-Agent': `a2acalling/${process.env.npm_package_version || 'dev'} (quickstart)`
958
- },
959
- timeout: timeoutMs
960
- }, (res) => {
961
- let data = '';
962
- res.setEncoding('utf8');
963
- res.on('data', (chunk) => {
964
- data += chunk;
965
- if (data.length > 1024 * 256) {
966
- req.destroy(new Error('response_too_large'));
967
- }
968
- });
969
- res.on('end', () => resolve({ statusCode: res.statusCode || 0, body: data }));
970
- });
971
- req.on('error', reject);
972
- req.on('timeout', () => req.destroy(new Error('timeout')));
973
- req.end();
974
- });
975
- }
976
-
977
- async function probeLocalPing(port, timeoutMs = 1000) {
978
- try {
979
- const res = await fetchUrlText(`http://127.0.0.1:${port}/api/a2a/ping`, timeoutMs);
980
- return { ok: looksLikePong(res.body), statusCode: res.statusCode, body: res.body };
981
- } catch (err) {
982
- return { ok: false, error: err && err.message ? err.message : 'request_failed' };
983
- }
984
- }
985
-
986
- async function externalPingCheck(targetUrl) {
987
- // Try direct access first. In practice this is the most reliable signal:
988
- // it avoids flaky third-party proxies and catches obvious scheme/port mistakes.
989
- try {
990
- const direct = await fetchUrlText(targetUrl, 2500);
991
- return { ok: looksLikePong(direct.body), provider: 'direct', statusCode: direct.statusCode };
992
- } catch (err) {
993
- // Fall back to remote fetch providers below.
994
- }
995
-
996
- const providers = [
997
- {
998
- name: 'allorigins',
999
- buildUrl: () => {
1000
- const u = new URL('https://api.allorigins.win/raw');
1001
- u.searchParams.set('url', targetUrl);
1002
- return u.toString();
1003
- }
1004
- },
1005
- {
1006
- name: 'jina',
1007
- buildUrl: () => `https://r.jina.ai/${targetUrl}`
976
+ if (value && typeof value === 'object' && !Array.isArray(value) && 'topic' in value) {
977
+ return String(value.topic || '').trim();
1008
978
  }
1009
- ];
979
+ return '';
980
+ };
1010
981
 
1011
- for (const provider of providers) {
1012
- try {
1013
- // eslint-disable-next-line no-await-in-loop
1014
- const res = await fetchUrlText(provider.buildUrl(), 8000);
1015
- if (looksLikePong(res.body)) {
1016
- return { ok: true, provider: provider.name, statusCode: res.statusCode };
1017
- }
1018
- } catch (err) {
1019
- // try next
1020
- }
982
+ const out = [];
983
+ const seen = new Set();
984
+ for (const value of values) {
985
+ const text = normalizeValue(value);
986
+ if (!text) continue;
987
+ const key = text.toLowerCase();
988
+ if (seen.has(key)) continue;
989
+ seen.add(key);
990
+ out.push(text);
991
+ if (out.length >= limit) break;
1021
992
  }
1022
- return { ok: false };
993
+ return out;
1023
994
  }
1024
995
 
1025
- function slugify(value) {
1026
- return String(value || '')
1027
- .toLowerCase()
1028
- .replace(/['"]/g, '')
1029
- .replace(/[^a-z0-9]+/g, '-')
1030
- .replace(/^-+|-+$/g, '')
1031
- .slice(0, 60);
996
+ function normalizeTopicRecord(raw) {
997
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
998
+ return {
999
+ topic: String(raw.topic || '').trim(),
1000
+ detail: String(raw.detail || '').trim()
1001
+ };
1002
+ }
1003
+ return {
1004
+ topic: String(raw || '').trim(),
1005
+ detail: ''
1006
+ };
1032
1007
  }
1033
1008
 
1034
- function uniqueNonEmpty(items, limit = 24) {
1009
+ function uniqueTopicRecords(values, limit = 80) {
1035
1010
  const out = [];
1036
1011
  const seen = new Set();
1037
- for (const raw of items) {
1038
- const s = String(raw || '').trim();
1039
- if (!s) continue;
1040
- if (seen.has(s)) continue;
1041
- seen.add(s);
1042
- out.push(s);
1012
+ for (const value of values) {
1013
+ const item = normalizeTopicRecord(value);
1014
+ if (!item.topic) continue;
1015
+ const key = item.topic.toLowerCase();
1016
+ if (seen.has(key)) continue;
1017
+ seen.add(key);
1018
+ out.push(item);
1043
1019
  if (out.length >= limit) break;
1044
1020
  }
1045
1021
  return out;
1046
1022
  }
1047
1023
 
1048
- function extractSectionBullets(markdown, headingRegex) {
1049
- const text = String(markdown || '');
1050
- const match = text.match(new RegExp(`##\\s*(?:${headingRegex})[^\\n]*\\n([\\s\\S]*?)(?=\\n##|$)`, 'i'));
1051
- if (!match) return [];
1052
- return match[1]
1053
- .split('\n')
1054
- .map(l => l.trim())
1055
- .filter(l => l.startsWith('-') || l.startsWith('*'))
1056
- .map(l => l.replace(/^[\\s\\-\\*]+/, '').trim())
1057
- .filter(Boolean);
1024
+ function sanitizeSectionItems(values, limit = 80) {
1025
+ return uniqueTopicRecords(values, limit).map(item => ({
1026
+ topic: item.topic,
1027
+ detail: item.detail || ''
1028
+ }));
1058
1029
  }
1059
1030
 
1060
- function tierFromManifest(manifest, tier, fallback = []) {
1061
- const t = (manifest && manifest.topics && manifest.topics[tier]) ? manifest.topics[tier] : null;
1062
- if (!t) return fallback;
1063
- const items = []
1064
- .concat(Array.isArray(t.lead_with) ? t.lead_with : [])
1065
- .concat(Array.isArray(t.discuss_freely) ? t.discuss_freely : [])
1066
- .concat(Array.isArray(t.deflect) ? t.deflect : []);
1067
- const topics = items.map(x => (x && x.topic) ? x.topic : '').filter(Boolean);
1068
- return topics.length ? topics : fallback;
1031
+ function cloneDraft(draft = {}) {
1032
+ return JSON.parse(JSON.stringify(draft));
1033
+ }
1034
+
1035
+ function makeDraft(manifest) {
1036
+ const src = (manifest && manifest.topics) ? manifest.topics : {};
1037
+ return {
1038
+ public: {
1039
+ lead_with: sanitizeSectionItems((src.public && src.public.lead_with) || [], 60),
1040
+ discuss_freely: sanitizeSectionItems((src.public && src.public.discuss_freely) || [], 60),
1041
+ deflect: sanitizeSectionItems((src.public && src.public.deflect) || [], 60)
1042
+ },
1043
+ friends: {
1044
+ lead_with: sanitizeSectionItems((src.friends && src.friends.lead_with) || [], 60),
1045
+ discuss_freely: sanitizeSectionItems((src.friends && src.friends.discuss_freely) || [], 60),
1046
+ deflect: sanitizeSectionItems((src.friends && src.friends.deflect) || [], 60)
1047
+ },
1048
+ family: {
1049
+ lead_with: sanitizeSectionItems((src.family && src.family.lead_with) || [], 60),
1050
+ discuss_freely: sanitizeSectionItems((src.family && src.family.discuss_freely) || [], 60),
1051
+ deflect: sanitizeSectionItems((src.family && src.family.deflect) || [], 60)
1052
+ }
1053
+ };
1069
1054
  }
1070
1055
 
1071
- function buildTierRecommendations(contextFiles, manifest) {
1072
- const publicFallback = ['chat', 'openclaw', 'a2a'];
1073
- const friendsFallback = ['chat', 'search', 'openclaw', 'a2a'];
1074
- const familyFallback = ['chat', 'search', 'openclaw', 'a2a', 'tools', 'memory'];
1056
+ function summarizeLine(content, maxLen = 60) {
1057
+ const text = String(content || '').split('\n').map((line) => line.trim()).find((line) => {
1058
+ return line && !line.startsWith('#') && !line.startsWith('---') && line.length <= 220;
1059
+ });
1060
+ if (!text) {
1061
+ return 'found';
1062
+ }
1063
+ return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
1064
+ }
1065
+
1066
+ function countMemoryDocs(root) {
1067
+ try {
1068
+ const dir = path.join(root, 'memory');
1069
+ if (!fs.existsSync(dir)) return 0;
1070
+ return fs.readdirSync(dir).filter(name => name.endsWith('.md')).length;
1071
+ } catch (err) {
1072
+ return 0;
1073
+ }
1074
+ }
1075
1075
 
1076
- const rawPublic = tierFromManifest(manifest, 'public', publicFallback);
1077
- const rawFriends = tierFromManifest(manifest, 'friends', friendsFallback);
1078
- const rawFamily = tierFromManifest(manifest, 'family', familyFallback);
1076
+ function renderWorkspaceScan(contextFiles) {
1077
+ const memoryCount = countMemoryDocs(workspaceDir);
1078
+ console.log('\n🔍 Scanning workspace for context...\n');
1079
+ console.log('Found:');
1080
+ const rows = [
1081
+ { label: 'USER.md', found: Boolean(contextFiles.user), note: summarizeLine(contextFiles.user, 72) },
1082
+ { label: 'SOUL.md', found: Boolean(contextFiles.soul), note: summarizeLine(contextFiles.soul, 72) },
1083
+ { label: 'HEARTBEAT.md', found: Boolean(contextFiles.heartbeat), note: 'contains agent tasks, not disclosure topics' },
1084
+ { label: 'SKILL.md', found: Boolean(contextFiles.skill), note: null },
1085
+ { label: 'memory/*.md', found: memoryCount > 0, note: `${memoryCount} file${memoryCount === 1 ? '' : 's'}` }
1086
+ ];
1079
1087
 
1080
- const goalsFromUser = extractSectionBullets(contextFiles.user, 'Goals|Current|Seeking|Working On');
1081
- const baseGoals = goalsFromUser.length
1082
- ? goalsFromUser
1083
- : ['grow network', 'find collaborators', 'build in public'];
1088
+ for (const row of rows) {
1089
+ const check = row.found ? '✅' : '❌';
1090
+ const note = row.found && row.note ? ` — ${row.note}` : '';
1091
+ const skip = row.label === 'HEARTBEAT.md' && row.found ? ' (skipped)' : '';
1092
+ console.log(` ${check} ${row.label}${skip}${note}`);
1093
+ }
1094
+ console.log('');
1095
+ }
1084
1096
 
1085
- const publicTopics = uniqueNonEmpty(rawPublic.map(slugify).filter(Boolean), 16);
1086
- const friendsTopics = uniqueNonEmpty(rawFriends.map(slugify).filter(Boolean), 20);
1087
- const familyTopics = uniqueNonEmpty(rawFamily.map(slugify).filter(Boolean), 24);
1097
+ function sectionLabel(sectionName) {
1098
+ if (sectionName === 'lead_with') return 'Lead with';
1099
+ if (sectionName === 'discuss_freely') return 'Discuss freely';
1100
+ return 'Deflect';
1101
+ }
1102
+
1103
+ function flattenDraft(draft) {
1104
+ const flat = [];
1105
+ let index = 1;
1106
+ ['public', 'friends', 'family'].forEach((tier) => {
1107
+ ['lead_with', 'discuss_freely', 'deflect'].forEach((section) => {
1108
+ (draft[tier][section] || []).forEach((item, itemIndex) => {
1109
+ flat.push({
1110
+ index,
1111
+ tier,
1112
+ section,
1113
+ item,
1114
+ itemIndex,
1115
+ list: draft[tier][section]
1116
+ });
1117
+ index += 1;
1118
+ });
1119
+ });
1120
+ });
1121
+ return flat;
1122
+ }
1088
1123
 
1089
- const goals = uniqueNonEmpty(baseGoals.map(slugify).filter(Boolean), 12);
1124
+ function renderDraft(draft, neverDisclose) {
1125
+ console.log('\n📋 Proposed Permission Tiers');
1126
+ console.log('═'.repeat(60));
1090
1127
 
1091
- return {
1092
- public: { topics: publicTopics, goals: goals.slice(0, 6) },
1093
- friends: { topics: uniqueNonEmpty([...publicTopics, ...friendsTopics], 24), goals: goals.slice(0, 8) },
1094
- family: { topics: uniqueNonEmpty([...publicTopics, ...friendsTopics, ...familyTopics], 30), goals }
1128
+ let index = 1;
1129
+ const titleByTier = {
1130
+ public: 'PUBLIC (anyone can see):',
1131
+ friends: 'FRIENDS (trusted contacts):',
1132
+ family: 'FAMILY (inner circle):'
1095
1133
  };
1134
+
1135
+ ['public', 'friends', 'family'].forEach((tier) => {
1136
+ console.log(`\n${titleByTier[tier]}`);
1137
+ ['lead_with', 'discuss_freely', 'deflect'].forEach((section) => {
1138
+ console.log(` ${sectionLabel(section)}:`);
1139
+ const list = draft[tier][section] || [];
1140
+ if (list.length === 0) {
1141
+ console.log(' (none)');
1142
+ return;
1143
+ }
1144
+ list.forEach((item) => {
1145
+ const detail = item.detail ? ` — ${item.detail}` : '';
1146
+ console.log(` ${index}. ${item.topic}${detail}`);
1147
+ index += 1;
1148
+ });
1149
+ });
1150
+ });
1151
+
1152
+ console.log('\nNEVER DISCLOSE:');
1153
+ const staticNever = (neverDisclose || ['API keys', 'Other users\' data', 'Financial figures']);
1154
+ staticNever.forEach((item) => console.log(` • ${item}`));
1155
+ console.log('═'.repeat(60));
1156
+ return flattenDraft(draft);
1096
1157
  }
1097
1158
 
1098
- function printTierSummary(tiers) {
1099
- const format = (t) => {
1100
- const topics = (t.topics || []).join(' · ') || '(none)';
1101
- const goals = (t.goals || []).join(' · ') || '(none)';
1102
- return `Topics: ${topics}\nGoals: ${goals}`;
1103
- };
1104
- console.log('\nProposed permission tiers:\n');
1105
- console.log('PUBLIC');
1106
- console.log(format(tiers.public));
1107
- console.log('\nFRIENDS');
1108
- console.log(format(tiers.friends));
1109
- console.log('\nFAMILY');
1110
- console.log(format(tiers.family));
1111
- console.log('');
1159
+ function parseSections(target) {
1160
+ if (!target) return null;
1161
+ const [tierRaw, sectionRaw] = String(target).toLowerCase().split('.');
1162
+ if (!tierRaw || !sectionRaw) return null;
1163
+ if (!['public', 'friends', 'family'].includes(tierRaw)) return null;
1164
+
1165
+ const section = {
1166
+ lead: 'lead_with',
1167
+ lead_with: 'lead_with',
1168
+ discuss: 'discuss_freely',
1169
+ discuss_freely: 'discuss_freely',
1170
+ deflect: 'deflect'
1171
+ }[sectionRaw];
1172
+
1173
+ if (!section) return null;
1174
+ return { tier: tierRaw, section };
1112
1175
  }
1113
1176
 
1114
- // ── Step 1: Background bootstrap (config + manifest) ─────────
1115
- let contextFiles = {};
1116
- let manifest = {};
1117
- try {
1118
- contextFiles = disc.readContextFiles(workspaceDir);
1119
- const forceManifest = Boolean(args.flags.force || args.flags['regen-manifest'] || args.flags.regenManifest);
1120
- if (forceManifest) {
1121
- const generated = disc.generateDefaultManifest(contextFiles);
1122
- disc.saveManifest(generated);
1123
- manifest = generated;
1124
- } else {
1125
- manifest = disc.loadManifest();
1126
- if (!manifest || Object.keys(manifest).length === 0) {
1127
- const generated = disc.generateDefaultManifest(contextFiles);
1128
- disc.saveManifest(generated);
1129
- manifest = generated;
1130
- }
1131
- }
1132
- } catch (err) {
1133
- // Non-fatal: onboarding can proceed even if manifest fails.
1134
- contextFiles = {};
1135
- manifest = {};
1136
- }
1137
-
1138
- console.log('\nA2A deterministic onboarding');
1139
- console.log('──────────────────────────');
1140
-
1141
- // ── Step 2: Owner dashboard access (local + optional remote) ─
1142
- config.setOnboarding({ step: 'access' });
1143
-
1144
- const hostnameFlagRaw = args.flags.hostname !== undefined ? String(args.flags.hostname) : '';
1145
- const normalizedHostname = normalizeHostInput(hostnameFlagRaw);
1146
-
1147
- // Invite host controls the a2a:// hostname we hand out (and remote dashboard pairing URL).
1148
- let inviteHost = '';
1149
- if (normalizedHostname) {
1150
- const parsed = splitHostPort(normalizedHostname);
1151
- const publicPortRaw = args.flags['public-port'] || args.flags.publicPort || process.env.A2A_PUBLIC_PORT || 443;
1152
- const publicPort = Number.parseInt(String(publicPortRaw), 10);
1153
- inviteHost = parsed.port
1154
- ? normalizedHostname
1155
- : `${parsed.hostname}:${(Number.isFinite(publicPort) && publicPort > 0 && publicPort <= 65535) ? publicPort : 443}`;
1156
- config.setAgent({ hostname: inviteHost });
1157
- } else {
1158
- const existing = normalizeHostInput((config.getAgent() || {}).hostname || '');
1159
- inviteHost = existing || `localhost:${backendPort}`;
1160
- if (!existing) {
1161
- config.setAgent({ hostname: inviteHost });
1162
- }
1177
+ function splitCommand(input) {
1178
+ const raw = String(input || '').trim();
1179
+ if (!raw) return [];
1180
+ const match = raw.match(/"([^"]*)"|'([^']*)'|`([^`]*)`|\S+/g);
1181
+ if (!match) return [];
1182
+ return match.map((token) => {
1183
+ if ((token.startsWith('"') && token.endsWith('"')) ||
1184
+ (token.startsWith("'") && token.endsWith("'")) ||
1185
+ (token.startsWith('`') && token.endsWith('`'))) {
1186
+ return token.slice(1, -1);
1187
+ }
1188
+ return token;
1189
+ });
1163
1190
  }
1164
1191
 
1165
- const inviteParsed = splitHostPort(inviteHost);
1166
- const invitePort = inviteParsed.port;
1167
- const schemeOverride = String(process.env.A2A_PUBLIC_SCHEME || '').trim();
1168
- const inviteScheme = schemeOverride || ((!invitePort || invitePort === 443) ? 'https' : 'http');
1169
- const expectedPingUrl = `${inviteScheme}://${inviteHost}/api/a2a/ping`;
1170
- const inviteLooksLocal = isLocalOrUnroutableHost(inviteParsed.hostname);
1192
+ function findByIndex(draft, index) {
1193
+ const target = flattenDraft(draft).find(item => item.index === index);
1194
+ return target || null;
1195
+ }
1171
1196
 
1172
- console.log('\n2️⃣ Owner dashboard access');
1173
- console.log(`Local dashboard: http://127.0.0.1:${backendPort}/dashboard/`);
1174
- console.log(`Invite host: ${inviteHost}`);
1197
+ function readNameFromUserContext(content) {
1198
+ const lines = String(content || '').split('\n');
1199
+ for (const line of lines) {
1200
+ const trimmed = String(line || '').trim();
1201
+ if (!trimmed) continue;
1175
1202
 
1176
- if (inviteLooksLocal) {
1177
- console.log('Remote dashboard: not configured (invite host looks local/unroutable)');
1178
- console.log(' To enable remote access, rerun with: --hostname YOUR_DOMAIN:443');
1179
- } else {
1180
- const callbookStore = new CallbookStore();
1181
- if (!callbookStore.isAvailable()) {
1182
- console.log('Remote dashboard: Callbook Remote not available (storage unavailable)');
1183
- console.log(` Hint: ${callbookStore.getDbError ? callbookStore.getDbError() : 'storage_unavailable'}`);
1184
- } else {
1185
- const label = String(args.flags['device-label'] || args.flags.deviceLabel || 'Callbook Remote').trim().slice(0, 120);
1186
- const ttlHoursRaw = args.flags['callbook-ttl-hours'] || args.flags.callbookTtlHours || 24;
1187
- const ttlHours = Math.max(1, Math.min(168, Number.parseInt(String(ttlHoursRaw), 10) || 24));
1188
- const created = callbookStore.createProvisionCode({ label, ttlMs: ttlHours * 60 * 60 * 1000 });
1189
- if (created && created.success) {
1190
- const installUrl = `${inviteScheme}://${inviteHost}/callbook/install#code=${created.code}`;
1191
- console.log(`Remote dashboard: ${installUrl} (one-time, ${ttlHours}h)`);
1192
- } else {
1193
- console.log('Remote dashboard: failed to create install link');
1194
- console.log(` Hint: ${created && created.message ? created.message : (created && created.error ? created.error : 'unknown_error')}`);
1203
+ const nameMatch = trimmed.match(/^\*{0,2}Name:\*{0,2}\s*(.+)$/i);
1204
+ if (nameMatch && nameMatch[1]) {
1205
+ return String(nameMatch[1]).trim();
1206
+ }
1207
+
1208
+ if (/^(owner|ownername):/i.test(trimmed)) {
1209
+ const ownerMatch = trimmed.replace(/^[^:]+:\s*/, '');
1210
+ if (ownerMatch) return ownerMatch.trim();
1211
+ }
1212
+
1213
+ if (trimmed.startsWith('-') || trimmed.startsWith('*') || trimmed.startsWith('#')) {
1214
+ continue;
1215
+ }
1216
+
1217
+ if (/^[A-Za-z][\w\-,.\s]{2,}$/i.test(trimmed)) {
1218
+ const candidate = trimmed.split('|')[0].split('\t')[0].trim();
1219
+ if (candidate && candidate.length <= 80) {
1220
+ return candidate;
1221
+ }
1195
1222
  }
1196
1223
  }
1224
+ return '';
1197
1225
  }
1198
1226
 
1199
- // ── Step 3: Permission tiers (topics + goals) ───────────────
1200
- const onboardingAfterAccess = config.getOnboarding();
1201
- if (!onboardingAfterAccess.tiers_confirmed) {
1202
- const recommendations = buildTierRecommendations(contextFiles, manifest);
1203
-
1204
- const parseFreeTextList = (raw) => {
1205
- if (raw === undefined || raw === null || raw === true) return [];
1206
- const text = String(raw || '').trim();
1207
- if (!text) return [];
1208
- return text
1209
- .split(/[\n,]+/g)
1210
- .map(s => s.trim())
1211
- .filter(Boolean);
1212
- };
1213
-
1214
- const promptLine = async (question) => {
1215
- const readline = require('readline');
1216
- return await new Promise(resolve => {
1217
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1218
- rl.question(question, (answer) => {
1219
- rl.close();
1220
- resolve(String(answer || '').trim());
1221
- });
1222
- });
1223
- };
1224
-
1225
- // Optional owner override: Friends tier topics/interests (most important tier).
1226
- const interactive = Boolean(
1227
- args.flags.interactive ||
1228
- args.flags['ask-friends-topics'] ||
1229
- args.flags.askFriendsTopics
1230
- );
1231
- let friendsTopicsOverride = parseFreeTextList(args.flags['friends-topics'] || args.flags.friendsTopics);
1232
- const noWorkspaceContext = !contextFiles.user && !contextFiles.heartbeat && !contextFiles.soul &&
1233
- !contextFiles.memory && !contextFiles.claude;
1234
- const shouldPromptFriendsTopics = (interactive || noWorkspaceContext) &&
1235
- friendsTopicsOverride.length === 0 &&
1236
- process.stdin.isTTY &&
1237
- process.stdout.isTTY;
1238
- if (shouldPromptFriendsTopics) {
1239
- const suggested = (recommendations.friends.topics || []).slice(0, 12).join(', ');
1240
- const answer = await promptLine(`Friends-tier topics/interests (comma-separated).\nSuggested: ${suggested}\n> `);
1241
- friendsTopicsOverride = parseFreeTextList(answer);
1242
- }
1243
-
1244
- if (friendsTopicsOverride.length > 0) {
1245
- const normalized = uniqueNonEmpty(friendsTopicsOverride.map(slugify).filter(Boolean), 24);
1246
- recommendations.friends.topics = uniqueNonEmpty(
1247
- [...(recommendations.public.topics || []), ...normalized],
1248
- 24
1249
- );
1250
- recommendations.family.topics = uniqueNonEmpty(
1251
- [...(recommendations.friends.topics || []), ...(recommendations.family.topics || [])],
1252
- 30
1253
- );
1254
- }
1255
-
1256
- try {
1257
- config.setTier('public', recommendations.public);
1258
- config.setTier('friends', recommendations.friends);
1259
- config.setTier('family', recommendations.family);
1260
- } catch (err) {
1261
- console.error('\n❌ Tier configuration validation failed.');
1262
- console.error(` ${err.message}`);
1263
- if (err.hint) {
1264
- console.error(` Hint: ${err.hint}`);
1265
- }
1266
- console.error('');
1267
- process.exit(1);
1268
- }
1269
-
1270
- printTierSummary(recommendations);
1271
-
1272
- config.setOnboarding({
1273
- step: 'tiers',
1274
- tiers_confirmed: true
1275
- });
1276
- }
1277
-
1278
- // ── Step 4: Port scan + reverse proxy guidance (if needed) ──
1279
- console.log('\n4️⃣ Port scan + reverse proxy');
1280
- console.log(`Invite host: ${inviteHost}`);
1281
- console.log(`Expected ping URL: ${expectedPingUrl}\n`);
1282
-
1283
- const expectsReverseProxy = Boolean(
1284
- (invitePort === 80 && backendPort !== 80) ||
1285
- ((!invitePort || invitePort === 443) && backendPort !== 443)
1286
- );
1227
+ function flattenTopicStrings(section) {
1228
+ return uniqueNonEmpty((section || []).map(item => String(item && item.topic || '').trim()), 200)
1229
+ .filter(Boolean);
1230
+ }
1287
1231
 
1288
- if (expectsReverseProxy) {
1289
- const port80Listening = await isPortListening(80, '127.0.0.1', { timeoutMs: 500 });
1290
- const port80Bind = await tryBindPort(80, '0.0.0.0');
1291
- const port80Ping = port80Listening.listening ? await probeLocalPing(80) : { ok: false };
1292
-
1293
- console.log('Port 80:');
1294
- if (port80Ping.ok) {
1295
- console.log(' ✅ serves /api/a2a/ping (A2A detected on :80)');
1296
- } else if (port80Listening.listening) {
1297
- console.log(` ⚠️ has a listener (${port80Listening.code || 'in_use'})`);
1298
- } else if (!port80Bind.ok && port80Bind.code === 'EACCES') {
1299
- console.log(' ⚠️ appears free but is not bindable by this user (EACCES)');
1300
- } else if (port80Bind.ok) {
1301
- console.log(' ✅ free and bindable by this user');
1302
- } else {
1303
- console.log(` ⚠️ not bindable (${port80Bind.code || 'unknown'})`);
1232
+ async function editLoop(draft, neverDisclose, reloadManifest) {
1233
+ const shouldPrompt = process.stdin.isTTY && process.stdout.isTTY;
1234
+ if (!shouldPrompt) {
1235
+ console.log('\n⏩ Non-interactive shell detected. Proceeding with proposed topics.');
1236
+ renderDraft(draft, neverDisclose);
1237
+ return draft;
1304
1238
  }
1305
1239
 
1306
- console.log('\nReverse proxy required (example routes):');
1307
- console.log(` /api/a2a/* -> http://127.0.0.1:${backendPort}`);
1308
- console.log(` /dashboard/* -> http://127.0.0.1:${backendPort}`);
1309
- console.log(` /callbook/* -> http://127.0.0.1:${backendPort}`);
1310
- console.log('');
1311
- console.log('If you have configured your reverse proxy and want to continue, run:');
1312
- console.log(` a2a quickstart --hostname ${inviteHost} --port ${backendPort} --confirm-ingress`);
1313
- console.log('');
1314
- if (!args.flags['confirm-ingress']) {
1315
- return;
1316
- }
1317
- } else {
1318
- console.log('✅ No reverse proxy required based on invite host/port.');
1240
+ console.log('\nEdit commands:');
1241
+ console.log(' move N to TIER.SECTION — Move topic #N to a section');
1242
+ console.log(' remove N — Remove topic #N');
1243
+ console.log(' add TIER.SECTION "Topic" "Detail" Add a topic');
1244
+ console.log(' edit N topic "new" — Edit topic #N label');
1245
+ console.log(' edit N detail "new" — Edit topic #N detail');
1246
+ console.log(' reset — Rescan workspace and regenerate');
1247
+ console.log(' done — Save and continue\n');
1248
+
1249
+ let done = false;
1250
+ renderDraft(draft, neverDisclose);
1251
+
1252
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1253
+ return await new Promise((resolve) => {
1254
+ const finish = () => {
1255
+ if (!done) {
1256
+ done = true;
1257
+ resolve(draft);
1258
+ }
1259
+ };
1260
+
1261
+ const prompt = () => {
1262
+ rl.question('Your choice: ', (answer) => {
1263
+ const parts = splitCommand(answer);
1264
+ const command = String(parts[0] || '').toLowerCase();
1265
+ if (!parts.length) {
1266
+ renderDraft(draft, neverDisclose);
1267
+ return prompt();
1268
+ }
1269
+
1270
+ if (command === 'done') {
1271
+ rl.close();
1272
+ return finish();
1273
+ }
1274
+
1275
+ if (command === 'reset') {
1276
+ draft = cloneDraft(reloadManifest());
1277
+ renderDraft(draft, neverDisclose);
1278
+ return prompt();
1279
+ }
1280
+
1281
+ if (command === 'remove') {
1282
+ const target = findByIndex(draft, Number.parseInt(parts[1], 10));
1283
+ if (!target) {
1284
+ console.log(`Could not find topic #${parts[1]}.`);
1285
+ } else {
1286
+ target.list.splice(target.itemIndex, 1);
1287
+ console.log(`Removed topic #${parts[1]}.`);
1288
+ }
1289
+ renderDraft(draft, neverDisclose);
1290
+ return prompt();
1291
+ }
1292
+
1293
+ if (command === 'move') {
1294
+ const target = findByIndex(draft, Number.parseInt(parts[1], 10));
1295
+ const destination = parseSections(parts[2] === 'to' ? parts[3] : parts[2]);
1296
+ if (!target) {
1297
+ console.log(`Could not find topic #${parts[1]}.`);
1298
+ } else if (!destination) {
1299
+ console.log('Invalid target. Use format: move N to friends.lead');
1300
+ } else {
1301
+ target.list.splice(target.itemIndex, 1);
1302
+ draft[destination.tier][destination.section].push(target.item);
1303
+ console.log(`Moved topic #${parts[1]} to ${destination.tier}.${destination.section}`);
1304
+ }
1305
+ renderDraft(draft, neverDisclose);
1306
+ return prompt();
1307
+ }
1308
+
1309
+ if (command === 'add') {
1310
+ const destination = parseSections(parts[1]);
1311
+ const topic = parts[2];
1312
+ const detail = parts[3] || '';
1313
+ if (!destination || !topic) {
1314
+ console.log('Add format: add TIER.SECTION "Topic" "Detail"');
1315
+ } else {
1316
+ draft[destination.tier][destination.section].push({ topic, detail });
1317
+ console.log(`Added topic to ${destination.tier}.${destination.section}.`);
1318
+ }
1319
+ renderDraft(draft, neverDisclose);
1320
+ return prompt();
1321
+ }
1322
+
1323
+ if (command === 'edit') {
1324
+ const target = findByIndex(draft, Number.parseInt(parts[1], 10));
1325
+ const field = String(parts[2] || '').toLowerCase();
1326
+ const value = parts[3] || '';
1327
+ if (!target || !field || !['topic', 'detail'].includes(field)) {
1328
+ console.log('Edit format: edit N topic "new" | edit N detail "new"');
1329
+ } else {
1330
+ target.item[field] = value;
1331
+ console.log(`Updated topic #${parts[1]} ${field}.`);
1332
+ }
1333
+ renderDraft(draft, neverDisclose);
1334
+ return prompt();
1335
+ }
1336
+
1337
+ console.log('Unknown command.');
1338
+ renderDraft(draft, neverDisclose);
1339
+ return prompt();
1340
+ });
1341
+ };
1342
+
1343
+ rl.on('close', finish);
1344
+ prompt();
1345
+ });
1346
+ }
1347
+
1348
+ async function probePing(port) {
1349
+ return await new Promise((resolve) => {
1350
+ const req = http.request({
1351
+ hostname: '127.0.0.1',
1352
+ port,
1353
+ path: '/api/a2a/ping',
1354
+ method: 'GET',
1355
+ timeout: 1200
1356
+ }, (res) => {
1357
+ let body = '';
1358
+ res.setEncoding('utf8');
1359
+ res.on('data', chunk => { body += String(chunk || ''); });
1360
+ res.on('end', () => {
1361
+ const ok = body.includes('"pong":true') || body.includes('"pong": true');
1362
+ resolve({ ok, statusCode: res.statusCode || 0, body });
1363
+ });
1364
+ });
1365
+ req.on('error', () => resolve({ ok: false }));
1366
+ req.on('timeout', () => {
1367
+ req.destroy(new Error('timeout'));
1368
+ resolve({ ok: false });
1369
+ });
1370
+ req.end();
1371
+ });
1372
+ }
1373
+
1374
+ async function waitForLocalServer(port) {
1375
+ for (let i = 0; i < 18; i++) {
1376
+ const listening = await isPortListening(port, '127.0.0.1', { timeoutMs: 250 });
1377
+ if (!listening.listening) {
1378
+ await new Promise(r => setTimeout(r, 250));
1379
+ continue;
1380
+ }
1381
+
1382
+ const probe = await probePing(port);
1383
+ if (probe.ok) {
1384
+ return true;
1385
+ }
1386
+ }
1387
+ return false;
1388
+ }
1389
+
1390
+ async function startServer(port) {
1391
+ const listening = await isPortListening(port, '127.0.0.1', { timeoutMs: 250 });
1392
+ if (listening.listening) {
1393
+ return false;
1394
+ }
1395
+
1396
+ const serverScript = path.join(__dirname, '../src/server.js');
1397
+ const child = spawn(process.execPath, [serverScript], {
1398
+ env: {
1399
+ ...process.env,
1400
+ PORT: String(port),
1401
+ A2A_WORKSPACE: workspaceDir
1402
+ },
1403
+ detached: true,
1404
+ stdio: 'ignore'
1405
+ });
1406
+ child.unref();
1407
+ await new Promise(r => setTimeout(r, 300));
1408
+ return true;
1319
1409
  }
1320
1410
 
1321
- if (!config.getOnboarding().ingress_confirmed) {
1322
- config.setOnboarding({
1323
- step: 'ingress',
1324
- ingress_confirmed: true
1411
+ function looksLikePong(body) {
1412
+ try {
1413
+ const parsed = JSON.parse(String(body || ''));
1414
+ if (parsed && parsed.pong === true) return true;
1415
+ } catch (e) {}
1416
+ return String(body || '').includes('"pong":true') || String(body || '').includes('"pong": true');
1417
+ }
1418
+
1419
+ // Step 1: discover context
1420
+ const contextFiles = (() => {
1421
+ try {
1422
+ return readContextFiles(workspaceDir);
1423
+ } catch (err) {
1424
+ return {};
1425
+ }
1426
+ })();
1427
+
1428
+ renderWorkspaceScan(contextFiles);
1429
+
1430
+ const backendPort = parsePort(args.flags.port || args.flags.p || process.env.A2A_PORT || process.env.PORT, 3001);
1431
+ const hostFlag = normalizeHostInput(
1432
+ args.flags.hostname !== undefined
1433
+ ? String(args.flags.hostname)
1434
+ : (config.getAgent().hostname || `localhost:${backendPort}`)
1435
+ );
1436
+ const parsedHost = splitHostPort(hostFlag || `localhost:${backendPort}`);
1437
+ const inviteHost = parsedHost.port
1438
+ ? `${parsedHost.hostname}:${parsedHost.port}`
1439
+ : `${parsedHost.hostname || 'localhost'}:${backendPort}`;
1440
+
1441
+ // Step 2: seed draft from workspace context
1442
+ let manifest = generateDefaultManifest(contextFiles);
1443
+ let draft = makeDraft(manifest);
1444
+ const neverDisclose = uniqueNonEmpty(manifest.never_disclose || [
1445
+ 'API keys',
1446
+ 'Other users\' data',
1447
+ 'Financial figures'
1448
+ ], 30);
1449
+
1450
+ draft = await editLoop(draft, neverDisclose, () => {
1451
+ try {
1452
+ const refreshedContext = readContextFiles(workspaceDir);
1453
+ const freshManifest = generateDefaultManifest(refreshedContext);
1454
+ manifest = freshManifest;
1455
+ return makeDraft(freshManifest);
1456
+ } catch (err) {
1457
+ return draft;
1458
+ }
1459
+ });
1460
+
1461
+ const finalManifest = {
1462
+ version: 1,
1463
+ generated_at: manifest.generated_at || new Date().toISOString(),
1464
+ updated_at: new Date().toISOString(),
1465
+ topics: {
1466
+ public: {
1467
+ lead_with: sanitizeSectionItems(draft.public.lead_with, 80),
1468
+ discuss_freely: sanitizeSectionItems(draft.public.discuss_freely, 80),
1469
+ deflect: sanitizeSectionItems(draft.public.deflect, 80)
1470
+ },
1471
+ friends: {
1472
+ lead_with: sanitizeSectionItems(draft.friends.lead_with, 80),
1473
+ discuss_freely: sanitizeSectionItems(draft.friends.discuss_freely, 80),
1474
+ deflect: sanitizeSectionItems(draft.friends.deflect, 80)
1475
+ },
1476
+ family: {
1477
+ lead_with: sanitizeSectionItems(draft.family.lead_with, 80),
1478
+ discuss_freely: sanitizeSectionItems(draft.family.discuss_freely, 80),
1479
+ deflect: sanitizeSectionItems(draft.family.deflect, 80)
1480
+ }
1481
+ },
1482
+ never_disclose: neverDisclose,
1483
+ personality_notes: manifest.personality_notes || ''
1484
+ };
1485
+
1486
+ // Keep config in sync with the edited disclosure.
1487
+ try {
1488
+ config.setTier('public', {
1489
+ topics: flattenTopicStrings([...finalManifest.topics.public.lead_with, ...finalManifest.topics.public.discuss_freely, ...finalManifest.topics.public.deflect]),
1490
+ disclosure: 'public'
1491
+ });
1492
+
1493
+ config.setTier('friends', {
1494
+ topics: flattenTopicStrings([
1495
+ ...finalManifest.topics.public.lead_with,
1496
+ ...finalManifest.topics.public.discuss_freely,
1497
+ ...finalManifest.topics.public.deflect,
1498
+ ...finalManifest.topics.friends.lead_with,
1499
+ ...finalManifest.topics.friends.discuss_freely,
1500
+ ...finalManifest.topics.friends.deflect
1501
+ ]),
1502
+ disclosure: 'minimal'
1503
+ });
1504
+
1505
+ config.setTier('family', {
1506
+ topics: flattenTopicStrings([
1507
+ ...finalManifest.topics.public.lead_with,
1508
+ ...finalManifest.topics.public.discuss_freely,
1509
+ ...finalManifest.topics.public.deflect,
1510
+ ...finalManifest.topics.friends.lead_with,
1511
+ ...finalManifest.topics.friends.discuss_freely,
1512
+ ...finalManifest.topics.friends.deflect,
1513
+ ...finalManifest.topics.family.lead_with,
1514
+ ...finalManifest.topics.family.discuss_freely,
1515
+ ...finalManifest.topics.family.deflect
1516
+ ]),
1517
+ disclosure: 'minimal'
1325
1518
  });
1519
+
1520
+ saveManifest(finalManifest);
1521
+ config.setOnboarding({ step: 'tiers', tiers_confirmed: true });
1522
+ } catch (err) {
1523
+ console.error('\n❌ Failed to save tier updates.');
1524
+ console.error(` ${err.message}`);
1525
+ throw err;
1326
1526
  }
1327
1527
 
1328
- // ── Step 5: External IP + reachability check ───────────────
1329
- console.log('\n5️⃣ External IP + reachability check');
1528
+ console.log('\n🚀 Starting A2A server...');
1529
+ console.log(`Port: ${backendPort}`);
1530
+ console.log(`Hostname: ${inviteHost}`);
1330
1531
 
1331
- if (inviteLooksLocal) {
1332
- console.log('Skipping external IP probe: invite host looks local/unroutable.');
1532
+ const started = await startServer(backendPort);
1533
+ const localRunning = await waitForLocalServer(backendPort);
1534
+ if (!localRunning) {
1535
+ console.log('⚠️ Local server not reachable. Start it manually and retry if needed:');
1536
+ console.log(` A2A_HOSTNAME="${inviteHost}" a2a server --port ${backendPort}`);
1333
1537
  } else {
1334
- const external = await getExternalIp({ forceRefresh: true });
1335
- if (external && external.ip) {
1336
- console.log(`External IP (${external.source || 'resolver'}): ${external.ip}`);
1337
- } else {
1338
- console.log(`External IP lookup failed: ${external && external.error ? external.error : 'unknown_error'}`);
1538
+ console.log('✅ Server running!');
1539
+ if (started) {
1540
+ console.log('🟢 Local server started automatically.');
1339
1541
  }
1340
1542
  }
1341
1543
 
1342
- const localListener = await isPortListening(backendPort, '127.0.0.1', { timeoutMs: 500 });
1343
- if (!localListener.listening) {
1344
- console.log('\n⚠️ A2A server is not reachable locally yet.');
1345
- console.log('Start it, then rerun quickstart:');
1346
- console.log(` A2A_HOSTNAME="${inviteHost}" a2a server --port ${backendPort}`);
1347
- console.log('');
1348
- return;
1349
- }
1350
- const localPing = await probeLocalPing(backendPort, inviteLooksLocal ? 250 : 1000);
1351
- if (!localPing.ok) {
1352
- if (inviteLooksLocal) {
1353
- console.log(`\n⚠️ Port ${backendPort} is listening but /api/a2a/ping did not respond within a short timeout.`);
1354
- console.log('Continuing onboarding anyway (invite host is local/unroutable).');
1355
- } else {
1356
- console.log('\n⚠️ A2A server is not responding locally yet.');
1357
- console.log('Start it, then rerun quickstart:');
1358
- console.log(` A2A_HOSTNAME="${inviteHost}" a2a server --port ${backendPort}`);
1359
- console.log('');
1360
- return;
1361
- }
1362
- }
1363
-
1364
- if (inviteLooksLocal) {
1365
- console.log('Skipping external reachability check: invite host looks local/unroutable.');
1366
- } else {
1367
- const extPing = await externalPingCheck(expectedPingUrl);
1368
- if (extPing.ok) {
1369
- console.log(`✅ External ping OK (${extPing.provider})`);
1370
- } else if (args.flags['skip-verify']) {
1371
- console.log('⚠️ External ping FAILED (skipped via --skip-verify).');
1372
- } else if (args.flags['confirm-ingress']) {
1373
- console.log('⚠️ External ping FAILED (continuing due to --confirm-ingress).');
1374
- } else {
1375
- console.log('⚠️ External ping FAILED (server may not be publicly reachable yet).');
1376
- console.log('Fix ingress (DNS/reverse proxy/firewall), then rerun. If you want to proceed anyway:');
1377
- console.log(` a2a quickstart --hostname ${inviteHost} --port ${backendPort} --confirm-ingress`);
1378
- console.log(` a2a quickstart --hostname ${inviteHost} --port ${backendPort} --skip-verify`);
1379
- console.log('');
1380
- return;
1381
- }
1382
- }
1383
-
1384
- if (!config.getOnboarding().verify_confirmed) {
1385
- config.setOnboarding({
1386
- step: 'verify',
1387
- verify_confirmed: true
1544
+ const dashboard = `http://127.0.0.1:${backendPort}/dashboard/`;
1545
+
1546
+ const hostSplit = splitHostPort(inviteHost);
1547
+ const isPrivateHost = isLocalOrUnroutableHost(hostSplit.hostname);
1548
+ const expectedPingUrl = `${isPrivateHost ? 'http' : (hostSplit.port === 443 ? 'https' : 'http')}://${inviteHost}/api/a2a/ping`;
1549
+
1550
+ if (isPrivateHost) {
1551
+ console.log('✅ External ping OK (local testing host)');
1552
+ } else {
1553
+ const external = await new Promise(resolve => {
1554
+ const req = http.get(expectedPingUrl, (res) => {
1555
+ let body = '';
1556
+ res.setEncoding('utf8');
1557
+ res.on('data', chunk => { body += chunk; });
1558
+ res.on('end', () => {
1559
+ resolve({ ok: looksLikePong(body), statusCode: res.statusCode || 0, body });
1560
+ });
1561
+ });
1562
+ req.on('error', () => resolve({ ok: false }));
1563
+ req.setTimeout(1500, () => {
1564
+ req.destroy(new Error('timeout'));
1565
+ resolve({ ok: false });
1566
+ });
1388
1567
  });
1568
+
1569
+ if (!external.ok && !args.flags['confirm-ingress'] && !args.flags['skip-verify']) {
1570
+ console.log('⚠️ External ping FAILED. Fix host/reachability and rerun quickstart, or use --skip-verify.');
1571
+ console.log(` a2a quickstart --hostname ${inviteHost} --port ${backendPort} --skip-verify`);
1572
+ return;
1573
+ }
1574
+
1575
+ if (!external.ok) {
1576
+ console.log('⚠️ External ping FAILED (continuing).');
1577
+ } else {
1578
+ console.log(`✅ External ping OK (${expectedPingUrl})`);
1579
+ }
1389
1580
  }
1390
1581
 
1582
+ console.log(`Dashboard: ${dashboard}`);
1583
+
1584
+ // Step 5: generate first invite
1585
+ const publicTopicsForInvite = flattenTopicStrings([
1586
+ ...draft.public.lead_with,
1587
+ ...draft.public.discuss_freely
1588
+ ]);
1589
+ const goalItems = ['grow-network', 'find-collaborators', 'build-in-public'];
1590
+
1591
+ const ownerName = args.flags.owner || config.getAgent().name || readNameFromUserContext(contextFiles.user) || 'Someone';
1592
+ const peerName = args.flags.name || 'my-agent';
1593
+
1594
+ config.setAgent({ name: ownerName, hostname: inviteHost });
1595
+
1596
+ const { token, record } = store.create({
1597
+ name: peerName,
1598
+ owner: ownerName,
1599
+ permissions: 'public',
1600
+ disclosure: 'minimal',
1601
+ expires: 'never',
1602
+ maxCalls: null,
1603
+ allowedTopics: publicTopicsForInvite,
1604
+ allowedGoals: goalItems,
1605
+ notify: 'all'
1606
+ });
1607
+
1608
+ const inviteUrl = `a2a://${inviteHost}/${token}`;
1609
+ const topicLine = publicTopicsForInvite.length > 0 ? publicTopicsForInvite.slice(0, 6).join(' · ') : 'chat';
1610
+ const goalLine = goalItems.join(' · ');
1611
+
1612
+ console.log('\n📞 Your first invite (public tier):\n');
1613
+ console.log('─'.repeat(60));
1614
+ const inviteText = `📞🗣️ **Agent-to-Agent Call Invite**
1615
+
1616
+ 👤 **${ownerName}** would like your agent to call **${peerName}**
1617
+
1618
+ 💬 ${topicLine}
1619
+ 🎯 ${goalLine}
1620
+
1621
+ ${inviteUrl}
1622
+
1623
+ ── setup ──
1624
+ npm i -g a2acalling && a2a add "${inviteUrl}" "${peerName}" && a2a call "${peerName}" "Hello!"
1625
+ https://github.com/onthegonow/a2a_calling`;
1626
+ console.log(inviteText);
1627
+ console.log('─'.repeat(60));
1628
+ console.log('Share this invite to let other agents call you!\n');
1629
+
1391
1630
  config.completeOnboarding();
1392
- console.log('✅ Onboarding complete.');
1393
- console.log('Next: a2a gui or a2a create or a2a server');
1631
+
1632
+ console.log(' A2A setup complete!\n');
1633
+ console.log('Your agent is now:');
1634
+ console.log(` • Listening on ${inviteHost}`);
1635
+ console.log(' • Ready to receive calls');
1636
+ console.log(` • Configured with ${Object.keys(finalManifest.topics).length} permission tiers`);
1637
+ console.log('\nNext steps:');
1638
+ console.log(' a2a invite friends — Create a friends-tier invite');
1639
+ console.log(' a2a contacts — View your contacts');
1640
+ console.log(' a2a gui — Open the dashboard\n');
1641
+ console.log('Happy calling! 🤝');
1394
1642
  },
1395
1643
 
1396
1644
  install: () => {
@@ -1401,6 +1649,136 @@ https://github.com/onthegonow/a2a_calling`;
1401
1649
  require('../scripts/install-openclaw.js');
1402
1650
  },
1403
1651
 
1652
+ uninstall: async (args) => {
1653
+ const fs = require('fs');
1654
+ const path = require('path');
1655
+ const { spawnSync } = require('child_process');
1656
+
1657
+ const keepConfig = Boolean(args.flags['keep-config'] || args.flags.keepConfig);
1658
+ const force = Boolean(args.flags.force || args.flags.f);
1659
+
1660
+ const configDir = process.env.A2A_CONFIG_DIR ||
1661
+ process.env.OPENCLAW_CONFIG_DIR ||
1662
+ path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
1663
+
1664
+ const configFile = path.join(configDir, 'a2a-config.json');
1665
+ const disclosureFile = path.join(configDir, 'a2a-disclosure.json');
1666
+ const tokensFile = path.join(configDir, 'a2a-tokens.json');
1667
+ const dbFile = path.join(configDir, 'a2a-conversations.db');
1668
+ const logsDbFile = path.join(configDir, 'a2a-logs.db');
1669
+ const callbookDbFile = path.join(configDir, 'a2a-callbook.db');
1670
+
1671
+ console.log(`\n🗑️ A2A Uninstall`);
1672
+ console.log('─────────────────\n');
1673
+
1674
+ if (!keepConfig && !force) {
1675
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1676
+ console.error('Refusing to prompt without a TTY. Re-run with --force to confirm uninstall.');
1677
+ process.exit(1);
1678
+ }
1679
+
1680
+ const existing = [configFile, disclosureFile, tokensFile, dbFile, logsDbFile, callbookDbFile].filter(f => fs.existsSync(f));
1681
+ const list = existing.length ? existing.map(f => ` - ${f}`).join('\n') : ' (no local config/database files found)';
1682
+ const ok = await promptYesNo(
1683
+ `This will stop the pm2 process "a2a" and delete:\n${list}\nProceed? (y/N) `
1684
+ );
1685
+ if (!ok) {
1686
+ console.log('\nCancelled.\n');
1687
+ return;
1688
+ }
1689
+ }
1690
+
1691
+ function pm2Exists() {
1692
+ const res = spawnSync('pm2', ['--version'], { stdio: 'ignore', timeout: 4000 });
1693
+ if (res.error && res.error.code === 'ENOENT') return false;
1694
+ return res.status === 0;
1695
+ }
1696
+
1697
+ function pm2HasProcess(name) {
1698
+ const res = spawnSync('pm2', ['describe', name], { encoding: 'utf8', timeout: 6000 });
1699
+ if (res.error && res.error.code === 'ENOENT') return false;
1700
+ return res.status === 0;
1701
+ }
1702
+
1703
+ function pm2StopAndDelete(name) {
1704
+ if (!pm2Exists()) return { ok: true, skipped: true };
1705
+ if (!pm2HasProcess(name)) return { ok: true, skipped: true };
1706
+
1707
+ const stop = spawnSync('pm2', ['stop', name], { encoding: 'utf8', timeout: 8000 });
1708
+ if (stop.status !== 0) {
1709
+ const msg = (stop.stderr || stop.stdout || '').trim();
1710
+ return { ok: false, error: msg || 'pm2 stop failed' };
1711
+ }
1712
+
1713
+ const del = spawnSync('pm2', ['delete', name], { encoding: 'utf8', timeout: 8000 });
1714
+ if (del.status !== 0) {
1715
+ const msg = (del.stderr || del.stdout || '').trim();
1716
+ return { ok: false, error: msg || 'pm2 delete failed' };
1717
+ }
1718
+
1719
+ return { ok: true };
1720
+ }
1721
+
1722
+ function rmFileSafe(filePath) {
1723
+ try {
1724
+ fs.rmSync(filePath, { force: true });
1725
+ return { ok: true };
1726
+ } catch (err) {
1727
+ return { ok: false, error: err.message };
1728
+ }
1729
+ }
1730
+
1731
+ process.stdout.write('Stopping server... ');
1732
+ const stopped = pm2StopAndDelete('a2a');
1733
+ if (!stopped.ok) {
1734
+ console.log('❌');
1735
+ console.error(` ${stopped.error}`);
1736
+ process.exit(1);
1737
+ }
1738
+ console.log('✅');
1739
+
1740
+ let configOk = true;
1741
+ let dbOk = true;
1742
+
1743
+ if (!keepConfig) {
1744
+ process.stdout.write('Removing config... ');
1745
+ const c1 = rmFileSafe(configFile);
1746
+ const c2 = rmFileSafe(disclosureFile);
1747
+ const c3 = rmFileSafe(tokensFile);
1748
+ configOk = Boolean(c1.ok && c2.ok && c3.ok);
1749
+ console.log(configOk ? '✅' : '❌');
1750
+ if (!configOk) {
1751
+ if (!c1.ok) console.error(` ${configFile}: ${c1.error}`);
1752
+ if (!c2.ok) console.error(` ${disclosureFile}: ${c2.error}`);
1753
+ if (!c3.ok) console.error(` ${tokensFile}: ${c3.error}`);
1754
+ }
1755
+
1756
+ process.stdout.write('Removing database... ');
1757
+ const d1 = rmFileSafe(dbFile);
1758
+ const d2 = rmFileSafe(logsDbFile);
1759
+ const d3 = rmFileSafe(callbookDbFile);
1760
+ dbOk = Boolean(d1.ok && d2.ok && d3.ok);
1761
+ console.log(dbOk ? '✅' : '❌');
1762
+ if (!dbOk) {
1763
+ if (!d1.ok) console.error(` ${dbFile}: ${d1.error}`);
1764
+ if (!d2.ok) console.error(` ${logsDbFile}: ${d2.error}`);
1765
+ if (!d3.ok) console.error(` ${callbookDbFile}: ${d3.error}`);
1766
+ }
1767
+
1768
+ if (!configOk || !dbOk) {
1769
+ process.exit(1);
1770
+ }
1771
+ } else {
1772
+ console.log('Removing config... ⏭️');
1773
+ console.log('Removing database... ⏭️');
1774
+ }
1775
+
1776
+ console.log('\nTo complete removal:');
1777
+ console.log(' npm uninstall -g a2acalling\n');
1778
+ console.log(`Config preserved: ${keepConfig ? 'yes' : 'no'}`);
1779
+ console.log(`Location: ${configDir}`);
1780
+ },
1781
+
1404
1782
  update: async (args) => {
1405
1783
  const { execSync } = require('child_process');
1406
1784
  const path = require('path');
@@ -1508,46 +1886,78 @@ https://github.com/onthegonow/a2a_calling`;
1508
1886
  }
1509
1887
  },
1510
1888
 
1511
- onboard: (args) => {
1512
- const { A2AConfig } = require('../src/lib/config');
1513
- const { readContextFiles, generateDefaultManifest, saveManifest, MANIFEST_FILE } = require('../src/lib/disclosure');
1514
- const config = new A2AConfig();
1515
-
1516
- if (config.isOnboarded() && !args.flags.force) {
1517
- console.log('\u2705 Onboarding already complete. Use --force to regenerate the disclosure manifest.');
1518
- return;
1519
- }
1889
+ onboard: (args) => {
1890
+ const { A2AConfig } = require('../src/lib/config');
1891
+ const {
1892
+ readContextFiles,
1893
+ buildExtractionPrompt,
1894
+ validateDisclosureSubmission,
1895
+ saveManifest,
1896
+ MANIFEST_FILE
1897
+ } = require('../src/lib/disclosure');
1898
+ const config = new A2AConfig();
1899
+
1900
+ // ── Submit mode: agent sends structured JSON ──────────────
1901
+ const submitRaw = args.flags.submit;
1902
+ if (submitRaw) {
1903
+ let parsed;
1904
+ try {
1905
+ parsed = JSON.parse(String(submitRaw));
1906
+ } catch (e) {
1907
+ console.error('\n\u274c Invalid JSON in --submit flag.');
1908
+ console.error(` Parse error: ${e.message}\n`);
1909
+ process.exit(1);
1910
+ }
1520
1911
 
1521
- const workspaceDir = process.env.A2A_WORKSPACE || process.cwd();
1522
- console.log('\n\ud83d\ude80 A2A Onboarding\n' + '\u2550'.repeat(50) + '\n');
1523
- console.log('Scanning workspace for context...\n');
1912
+ const result = validateDisclosureSubmission(parsed);
1913
+ if (!result.valid) {
1914
+ console.error('\n\u274c Disclosure submission validation failed:\n');
1915
+ result.errors.forEach(err => console.error(` \u2022 ${err}`));
1916
+ console.error(`\nFix the errors above and resubmit with: a2a onboard --submit '<json>'\n`);
1917
+ process.exit(1);
1918
+ }
1524
1919
 
1920
+ saveManifest(result.manifest);
1921
+
1922
+ const agentName = args.flags.name || config.getAgent().name || process.env.A2A_AGENT_NAME || '';
1923
+ const hostname = args.flags.hostname || config.getAgent().hostname || process.env.A2A_HOSTNAME || '';
1924
+ if (agentName) config.setAgent({ name: agentName });
1925
+ if (hostname) config.setAgent({ hostname });
1926
+
1927
+ console.log('\n\u2705 Disclosure manifest saved.');
1928
+ console.log(` Manifest: ${MANIFEST_FILE}`);
1929
+ console.log(' Next: a2a quickstart\n');
1930
+ return;
1931
+ }
1932
+
1933
+ // ── Prompt mode: print extraction instructions for agent ──
1934
+ if (config.isOnboarded() && !args.flags.force) {
1935
+ console.log('\u2705 Onboarding already complete. Use --force to regenerate.');
1936
+ return;
1937
+ }
1938
+
1939
+ const workspaceDir = process.env.A2A_WORKSPACE || process.cwd();
1525
1940
  const contextFiles = readContextFiles(workspaceDir);
1526
- // Print what was found
1527
- const sources = {
1528
- 'USER.md': contextFiles.user,
1529
- 'HEARTBEAT.md': contextFiles.heartbeat,
1530
- 'SOUL.md': contextFiles.soul,
1531
- 'SKILL.md': contextFiles.skill,
1532
- 'CLAUDE.md': contextFiles.claude,
1533
- 'memory/*.md': contextFiles.memory
1534
- };
1535
- for (const [name, content] of Object.entries(sources)) {
1536
- console.log(` ${content ? '\u2705' : '\u274c'} ${name}`);
1537
- }
1538
-
1539
- const manifest = generateDefaultManifest(contextFiles);
1540
- saveManifest(manifest);
1541
-
1542
- const agentName = args.flags.name || config.getAgent().name || process.env.A2A_AGENT_NAME || '';
1543
- const hostname = args.flags.hostname || config.getAgent().hostname || process.env.A2A_HOSTNAME || '';
1544
- if (agentName) config.setAgent({ name: agentName });
1545
- if (hostname) config.setAgent({ hostname });
1546
-
1547
- console.log(`\n\u2705 Disclosure manifest generated.`);
1548
- console.log(` Manifest: ${MANIFEST_FILE}`);
1549
- console.log(' Next: a2a quickstart\n');
1550
- },
1941
+
1942
+ const availableFiles = {
1943
+ 'USER.md': Boolean(contextFiles.user),
1944
+ 'SOUL.md': Boolean(contextFiles.soul),
1945
+ 'HEARTBEAT.md': Boolean(contextFiles.heartbeat),
1946
+ 'SKILL.md': Boolean(contextFiles.skill),
1947
+ 'CLAUDE.md': Boolean(contextFiles.claude),
1948
+ 'memory/*.md': Boolean(contextFiles.memory)
1949
+ };
1950
+
1951
+ console.log(buildExtractionPrompt(availableFiles));
1952
+ console.log('\n---');
1953
+ console.log('After the owner confirms, submit with:');
1954
+ console.log(" a2a onboard --submit '<json>'\n");
1955
+ },
1956
+
1957
+ version: () => {
1958
+ const pkg = require('../package.json');
1959
+ console.log(pkg.version);
1960
+ },
1551
1961
 
1552
1962
  help: () => {
1553
1963
  console.log(`A2A Calling - Agent-to-Agent Communication
@@ -1628,6 +2038,10 @@ Server:
1628
2038
 
1629
2039
  install Install A2A for OpenClaw
1630
2040
  setup Auto setup (gateway-aware dashboard install)
2041
+ uninstall Stop server and remove local config/DB
2042
+ --keep-config Preserve config/DB (for reinstall)
2043
+ --force Skip confirmation prompt
2044
+ version Show installed package version
1631
2045
 
1632
2046
  Examples:
1633
2047
  a2a create --name "bappybot" --owner "Benjamin Pollack" --expires 7d
@@ -1651,6 +2065,8 @@ if (!commands[command]) {
1651
2065
  process.exit(1);
1652
2066
  }
1653
2067
 
2068
+ enforceOnboarding(command);
2069
+
1654
2070
  // Handle async commands
1655
2071
  const result = commands[command](args);
1656
2072
  if (result instanceof Promise) {