@crmy/cli 0.5.1
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/package.json +26 -0
- package/src/client.ts +287 -0
- package/src/commands/accounts.ts +78 -0
- package/src/commands/activity-types.ts +59 -0
- package/src/commands/actors.ts +80 -0
- package/src/commands/assignments.ts +164 -0
- package/src/commands/auth.ts +161 -0
- package/src/commands/briefing.ts +41 -0
- package/src/commands/config.ts +25 -0
- package/src/commands/contacts.ts +83 -0
- package/src/commands/context-types.ts +56 -0
- package/src/commands/context.ts +181 -0
- package/src/commands/custom-fields.ts +63 -0
- package/src/commands/emails.ts +71 -0
- package/src/commands/events.ts +54 -0
- package/src/commands/help.ts +81 -0
- package/src/commands/hitl.ts +50 -0
- package/src/commands/init.ts +129 -0
- package/src/commands/mcp.ts +70 -0
- package/src/commands/migrate.ts +49 -0
- package/src/commands/notes.ts +88 -0
- package/src/commands/opps.ts +106 -0
- package/src/commands/pipeline.ts +26 -0
- package/src/commands/search.ts +45 -0
- package/src/commands/server.ts +32 -0
- package/src/commands/use-cases.ts +97 -0
- package/src/commands/webhooks.ts +95 -0
- package/src/commands/workflows.ts +119 -0
- package/src/config.ts +74 -0
- package/src/index.ts +80 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { getClient } from '../client.js';
|
|
6
|
+
|
|
7
|
+
export function contextCommand(): Command {
|
|
8
|
+
const cmd = new Command('context').description('Manage context entries (knowledge & memory)');
|
|
9
|
+
|
|
10
|
+
cmd.command('list')
|
|
11
|
+
.option('--subject-type <type>', 'Filter by subject type (contact, account, opportunity, use_case)')
|
|
12
|
+
.option('--subject-id <id>', 'Filter by subject ID')
|
|
13
|
+
.option('--type <contextType>', 'Filter by context type (note, research, objection, etc.)')
|
|
14
|
+
.option('--current-only', 'Only show current entries (default behavior)')
|
|
15
|
+
.action(async (opts) => {
|
|
16
|
+
const client = await getClient();
|
|
17
|
+
const result = await client.call('context_list', {
|
|
18
|
+
subject_type: opts.subjectType,
|
|
19
|
+
subject_id: opts.subjectId,
|
|
20
|
+
context_type: opts.type,
|
|
21
|
+
is_current: opts.currentOnly ? true : undefined,
|
|
22
|
+
limit: 20,
|
|
23
|
+
});
|
|
24
|
+
const data = JSON.parse(result);
|
|
25
|
+
if (data.context_entries?.length === 0) {
|
|
26
|
+
console.log('No context entries found.');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
console.table(data.context_entries?.map((c: Record<string, unknown>) => ({
|
|
30
|
+
id: (c.id as string).slice(0, 8),
|
|
31
|
+
type: c.context_type,
|
|
32
|
+
title: ((c.title as string) ?? '').slice(0, 40),
|
|
33
|
+
subject: `${c.subject_type}:${(c.subject_id as string).slice(0, 8)}`,
|
|
34
|
+
confidence: c.confidence ?? '—',
|
|
35
|
+
current: c.is_current ? '✓' : '✗',
|
|
36
|
+
})));
|
|
37
|
+
if (data.total > 20) console.log(`\n Showing 20 of ${data.total} entries`);
|
|
38
|
+
await client.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
cmd.command('add')
|
|
42
|
+
.description('Add context about a CRM object')
|
|
43
|
+
.action(async () => {
|
|
44
|
+
const { default: inquirer } = await import('inquirer');
|
|
45
|
+
const answers = await inquirer.prompt([
|
|
46
|
+
{ type: 'list', name: 'subject_type', message: 'Subject type:', choices: ['contact', 'account', 'opportunity', 'use_case'] },
|
|
47
|
+
{ type: 'input', name: 'subject_id', message: 'Subject ID (UUID):' },
|
|
48
|
+
{ type: 'list', name: 'context_type', message: 'Context type:', choices: ['note', 'transcript', 'summary', 'research', 'preference', 'objection', 'competitive_intel', 'relationship_map', 'meeting_notes', 'agent_reasoning'] },
|
|
49
|
+
{ type: 'input', name: 'title', message: 'Title (optional):' },
|
|
50
|
+
{ type: 'editor', name: 'body', message: 'Body:' },
|
|
51
|
+
{ type: 'input', name: 'confidence', message: 'Confidence (0.0–1.0, optional):' },
|
|
52
|
+
{ type: 'input', name: 'source', message: 'Source (e.g. manual, call_transcript, agent_research):' },
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const client = await getClient();
|
|
56
|
+
const result = await client.call('context_add', {
|
|
57
|
+
subject_type: answers.subject_type,
|
|
58
|
+
subject_id: answers.subject_id,
|
|
59
|
+
context_type: answers.context_type,
|
|
60
|
+
title: answers.title || undefined,
|
|
61
|
+
body: answers.body,
|
|
62
|
+
confidence: answers.confidence ? parseFloat(answers.confidence) : undefined,
|
|
63
|
+
source: answers.source || undefined,
|
|
64
|
+
});
|
|
65
|
+
const data = JSON.parse(result);
|
|
66
|
+
console.log(`\n Added context: ${data.context_entry.id}\n`);
|
|
67
|
+
await client.close();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
cmd.command('get <id>')
|
|
71
|
+
.action(async (id) => {
|
|
72
|
+
const client = await getClient();
|
|
73
|
+
const result = await client.call('context_get', { id });
|
|
74
|
+
console.log(JSON.parse(result));
|
|
75
|
+
await client.close();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
cmd.command('supersede <id>')
|
|
79
|
+
.option('-b, --body <body>', 'New body text')
|
|
80
|
+
.option('-t, --title <title>', 'New title')
|
|
81
|
+
.description('Supersede an existing context entry with updated content')
|
|
82
|
+
.action(async (id, opts) => {
|
|
83
|
+
let body = opts.body;
|
|
84
|
+
if (!body) {
|
|
85
|
+
const { default: inquirer } = await import('inquirer');
|
|
86
|
+
const answers = await inquirer.prompt([
|
|
87
|
+
{ type: 'editor', name: 'body', message: 'Updated body:' },
|
|
88
|
+
]);
|
|
89
|
+
body = answers.body;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const client = await getClient();
|
|
93
|
+
const result = await client.call('context_supersede', {
|
|
94
|
+
id,
|
|
95
|
+
body,
|
|
96
|
+
title: opts.title || undefined,
|
|
97
|
+
});
|
|
98
|
+
const data = JSON.parse(result);
|
|
99
|
+
console.log(`\n Superseded with new entry: ${data.context_entry.id}\n`);
|
|
100
|
+
await client.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
cmd.command('search <query>')
|
|
104
|
+
.description('Full-text search across context entries')
|
|
105
|
+
.option('--subject <subject>', 'Filter by subject (type:UUID)')
|
|
106
|
+
.option('--type <contextType>', 'Filter by context type')
|
|
107
|
+
.option('--tag <tag>', 'Filter by tag')
|
|
108
|
+
.option('--include-superseded', 'Include non-current entries')
|
|
109
|
+
.option('--limit <n>', 'Max results', '20')
|
|
110
|
+
.action(async (query, opts) => {
|
|
111
|
+
const input: Record<string, unknown> = {
|
|
112
|
+
query,
|
|
113
|
+
limit: parseInt(opts.limit, 10),
|
|
114
|
+
current_only: !opts.includeSuperseded,
|
|
115
|
+
};
|
|
116
|
+
if (opts.subject) {
|
|
117
|
+
const [st, si] = opts.subject.split(':');
|
|
118
|
+
input.subject_type = st;
|
|
119
|
+
input.subject_id = si;
|
|
120
|
+
}
|
|
121
|
+
if (opts.type) input.context_type = opts.type;
|
|
122
|
+
if (opts.tag) input.tag = opts.tag;
|
|
123
|
+
|
|
124
|
+
const client = await getClient();
|
|
125
|
+
const result = await client.call('context_search', input);
|
|
126
|
+
const data = JSON.parse(result);
|
|
127
|
+
if (data.context_entries?.length === 0) {
|
|
128
|
+
console.log('No results found.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.table(data.context_entries?.map((c: Record<string, unknown>) => ({
|
|
132
|
+
id: (c.id as string).slice(0, 8),
|
|
133
|
+
type: c.context_type,
|
|
134
|
+
title: ((c.title as string) ?? '').slice(0, 40),
|
|
135
|
+
subject: `${c.subject_type}:${(c.subject_id as string).slice(0, 8)}`,
|
|
136
|
+
confidence: c.confidence ?? '—',
|
|
137
|
+
})));
|
|
138
|
+
await client.close();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
cmd.command('review <id>')
|
|
142
|
+
.description('Mark a context entry as reviewed (still accurate)')
|
|
143
|
+
.action(async (id) => {
|
|
144
|
+
const client = await getClient();
|
|
145
|
+
const result = await client.call('context_review', { id });
|
|
146
|
+
const data = JSON.parse(result);
|
|
147
|
+
console.log(`\n Reviewed context entry: ${data.context_entry.id} (reviewed_at: ${data.context_entry.reviewed_at})\n`);
|
|
148
|
+
await client.close();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
cmd.command('stale')
|
|
152
|
+
.description('List stale context entries that need review')
|
|
153
|
+
.option('--subject <subject>', 'Filter by subject (type:UUID)')
|
|
154
|
+
.option('--limit <n>', 'Max results', '20')
|
|
155
|
+
.action(async (opts) => {
|
|
156
|
+
const input: Record<string, unknown> = { limit: parseInt(opts.limit, 10) };
|
|
157
|
+
if (opts.subject) {
|
|
158
|
+
const [st, si] = opts.subject.split(':');
|
|
159
|
+
input.subject_type = st;
|
|
160
|
+
input.subject_id = si;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const client = await getClient();
|
|
164
|
+
const result = await client.call('context_stale', input);
|
|
165
|
+
const data = JSON.parse(result);
|
|
166
|
+
if (data.stale_entries?.length === 0) {
|
|
167
|
+
console.log('No stale entries found.');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
console.table(data.stale_entries?.map((c: Record<string, unknown>) => ({
|
|
171
|
+
id: (c.id as string).slice(0, 8),
|
|
172
|
+
type: c.context_type,
|
|
173
|
+
title: ((c.title as string) ?? '').slice(0, 40),
|
|
174
|
+
expired: c.valid_until,
|
|
175
|
+
subject: `${c.subject_type}:${(c.subject_id as string).slice(0, 8)}`,
|
|
176
|
+
})));
|
|
177
|
+
await client.close();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return cmd;
|
|
181
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { getClient } from '../client.js';
|
|
6
|
+
|
|
7
|
+
export function customFieldsCommand(): Command {
|
|
8
|
+
const cmd = new Command('custom-fields').description('Manage custom field definitions');
|
|
9
|
+
|
|
10
|
+
cmd.command('list <object_type>')
|
|
11
|
+
.description('List custom fields for an object type (contact, account, opportunity, activity, use_case)')
|
|
12
|
+
.action(async (objectType) => {
|
|
13
|
+
const client = await getClient();
|
|
14
|
+
const result = await client.call('custom_field_list', { object_type: objectType });
|
|
15
|
+
const data = JSON.parse(result);
|
|
16
|
+
if (data.fields?.length === 0) {
|
|
17
|
+
console.log(`No custom fields defined for ${objectType}.`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
console.table(data.fields?.map((f: Record<string, unknown>) => ({
|
|
21
|
+
id: (f.id as string).slice(0, 8),
|
|
22
|
+
key: f.field_key,
|
|
23
|
+
label: f.label,
|
|
24
|
+
type: f.field_type,
|
|
25
|
+
required: f.is_required,
|
|
26
|
+
})));
|
|
27
|
+
await client.close();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
cmd.command('create')
|
|
31
|
+
.action(async () => {
|
|
32
|
+
const { default: inquirer } = await import('inquirer');
|
|
33
|
+
const answers = await inquirer.prompt([
|
|
34
|
+
{
|
|
35
|
+
type: 'list', name: 'object_type', message: 'Object type:',
|
|
36
|
+
choices: ['contact', 'account', 'opportunity', 'activity', 'use_case'],
|
|
37
|
+
},
|
|
38
|
+
{ type: 'input', name: 'field_name', message: 'Field key (snake_case):' },
|
|
39
|
+
{ type: 'input', name: 'label', message: 'Display label:' },
|
|
40
|
+
{
|
|
41
|
+
type: 'list', name: 'field_type', message: 'Field type:',
|
|
42
|
+
choices: ['text', 'number', 'boolean', 'date', 'select', 'multi_select'],
|
|
43
|
+
},
|
|
44
|
+
{ type: 'confirm', name: 'required', message: 'Required?', default: false },
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const client = await getClient();
|
|
48
|
+
const result = await client.call('custom_field_create', answers);
|
|
49
|
+
const data = JSON.parse(result);
|
|
50
|
+
console.log(`\n Created custom field: ${data.field.id}\n`);
|
|
51
|
+
await client.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
cmd.command('delete <id>')
|
|
55
|
+
.action(async (id) => {
|
|
56
|
+
const client = await getClient();
|
|
57
|
+
const result = await client.call('custom_field_delete', { id });
|
|
58
|
+
console.log(JSON.parse(result));
|
|
59
|
+
await client.close();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return cmd;
|
|
63
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { getClient } from '../client.js';
|
|
6
|
+
|
|
7
|
+
export function emailsCommand(): Command {
|
|
8
|
+
const cmd = new Command('emails').description('Manage outbound emails');
|
|
9
|
+
|
|
10
|
+
cmd.command('list')
|
|
11
|
+
.option('--contact <id>', 'Filter by contact ID')
|
|
12
|
+
.option('--status <status>', 'Filter by status')
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
const client = await getClient();
|
|
15
|
+
const result = await client.call('email_search', {
|
|
16
|
+
contact_id: opts.contact,
|
|
17
|
+
status: opts.status,
|
|
18
|
+
limit: 20,
|
|
19
|
+
});
|
|
20
|
+
const data = JSON.parse(result);
|
|
21
|
+
if (data.emails?.length === 0) {
|
|
22
|
+
console.log('No emails found.');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
console.table(data.emails?.map((e: Record<string, unknown>) => ({
|
|
26
|
+
id: (e.id as string).slice(0, 8),
|
|
27
|
+
to: e.to_email,
|
|
28
|
+
subject: e.subject,
|
|
29
|
+
status: e.status,
|
|
30
|
+
created: e.created_at,
|
|
31
|
+
})));
|
|
32
|
+
await client.close();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
cmd.command('get <id>')
|
|
36
|
+
.action(async (id) => {
|
|
37
|
+
const client = await getClient();
|
|
38
|
+
const result = await client.call('email_get', { id });
|
|
39
|
+
console.log(JSON.parse(result));
|
|
40
|
+
await client.close();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
cmd.command('create')
|
|
44
|
+
.action(async () => {
|
|
45
|
+
const { default: inquirer } = await import('inquirer');
|
|
46
|
+
const answers = await inquirer.prompt([
|
|
47
|
+
{ type: 'input', name: 'to_address', message: 'To (email):' },
|
|
48
|
+
{ type: 'input', name: 'subject', message: 'Subject:' },
|
|
49
|
+
{ type: 'input', name: 'body_text', message: 'Body:' },
|
|
50
|
+
{ type: 'input', name: 'contact_id', message: 'Contact ID (optional):' },
|
|
51
|
+
{ type: 'confirm', name: 'require_approval', message: 'Require HITL approval?', default: true },
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const client = await getClient();
|
|
55
|
+
const result = await client.call('email_create', {
|
|
56
|
+
to_address: answers.to_address,
|
|
57
|
+
subject: answers.subject,
|
|
58
|
+
body_text: answers.body_text,
|
|
59
|
+
contact_id: answers.contact_id || undefined,
|
|
60
|
+
require_approval: answers.require_approval,
|
|
61
|
+
});
|
|
62
|
+
const data = JSON.parse(result);
|
|
63
|
+
console.log(`\n Created email: ${data.email.id} status: ${data.email.status}\n`);
|
|
64
|
+
if (data.hitl_request_id) {
|
|
65
|
+
console.log(` HITL approval required: ${data.hitl_request_id}\n`);
|
|
66
|
+
}
|
|
67
|
+
await client.close();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return cmd;
|
|
71
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { loadConfigFile } from '../config.js';
|
|
6
|
+
|
|
7
|
+
export function eventsCommand(): Command {
|
|
8
|
+
return new Command('events')
|
|
9
|
+
.description('View audit log')
|
|
10
|
+
.option('--object <id>', 'Filter by object ID')
|
|
11
|
+
.option('--type <type>', 'Filter by event type')
|
|
12
|
+
.action(async (opts) => {
|
|
13
|
+
const config = loadConfigFile();
|
|
14
|
+
const databaseUrl = process.env.DATABASE_URL ?? config.database?.url;
|
|
15
|
+
if (!databaseUrl) {
|
|
16
|
+
console.error('No database URL configured.');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
process.env.CRMY_IMPORTED = '1';
|
|
21
|
+
const { initPool, closePool } = await import('@crmy/server');
|
|
22
|
+
const { searchEvents } = await import('@crmy/server/dist/db/repos/events.js');
|
|
23
|
+
|
|
24
|
+
const db = await initPool(databaseUrl);
|
|
25
|
+
|
|
26
|
+
// Get default tenant
|
|
27
|
+
const tenantResult = await db.query("SELECT id FROM tenants WHERE slug = 'default' LIMIT 1");
|
|
28
|
+
if (tenantResult.rows.length === 0) {
|
|
29
|
+
console.log('No tenant found. Run crmy init first.');
|
|
30
|
+
await closePool();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const result = await searchEvents(db, tenantResult.rows[0].id, {
|
|
35
|
+
object_id: opts.object,
|
|
36
|
+
event_type: opts.type,
|
|
37
|
+
limit: 50,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (result.data.length === 0) {
|
|
41
|
+
console.log('No events found.');
|
|
42
|
+
} else {
|
|
43
|
+
console.table(result.data.map((e) => ({
|
|
44
|
+
id: e.id,
|
|
45
|
+
type: e.event_type,
|
|
46
|
+
actor: e.actor_type,
|
|
47
|
+
object: e.object_type,
|
|
48
|
+
created: e.created_at,
|
|
49
|
+
})));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
await closePool();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
|
|
6
|
+
const HELP_TEXT = `
|
|
7
|
+
CRMy — The agent-first open source CRM
|
|
8
|
+
|
|
9
|
+
Usage: crmy <command> [options]
|
|
10
|
+
|
|
11
|
+
Setup & Auth
|
|
12
|
+
init Initialize crmy: configure database, run migrations, create user
|
|
13
|
+
login Sign in to a CRMy server (shortcut for crmy auth login)
|
|
14
|
+
auth Manage authentication (login, logout, whoami)
|
|
15
|
+
config View and update local configuration
|
|
16
|
+
migrate Run database migrations
|
|
17
|
+
|
|
18
|
+
Server
|
|
19
|
+
server Start the CRMy server
|
|
20
|
+
mcp Start the MCP stdio server for Claude Code
|
|
21
|
+
|
|
22
|
+
CRM Data
|
|
23
|
+
contacts Manage contacts (list, get, create, update, delete)
|
|
24
|
+
accounts Manage accounts (list, get, create, update, delete)
|
|
25
|
+
opps Manage opportunities (list, get, create, update, delete)
|
|
26
|
+
pipeline View and manage the sales pipeline
|
|
27
|
+
notes Manage notes on CRM objects
|
|
28
|
+
custom-fields Manage custom field definitions
|
|
29
|
+
search Search across contacts, accounts, and opportunities
|
|
30
|
+
|
|
31
|
+
Automation
|
|
32
|
+
workflows Manage automation workflows
|
|
33
|
+
events View the event log
|
|
34
|
+
webhooks Manage webhook subscriptions
|
|
35
|
+
emails Send and manage emails
|
|
36
|
+
hitl Human-in-the-loop approval queue
|
|
37
|
+
|
|
38
|
+
Resources
|
|
39
|
+
use-cases Browse example use cases and templates
|
|
40
|
+
|
|
41
|
+
Options
|
|
42
|
+
-V, --version Output the version number
|
|
43
|
+
-h, --help Display help for a command
|
|
44
|
+
|
|
45
|
+
Examples
|
|
46
|
+
$ crmy init Set up a new CRMy instance
|
|
47
|
+
$ crmy server Start the server on :3000
|
|
48
|
+
$ crmy contacts list List all contacts
|
|
49
|
+
$ crmy opps create Create a new opportunity
|
|
50
|
+
$ crmy mcp Start MCP server for Claude Code
|
|
51
|
+
|
|
52
|
+
Run crmy <command> --help for detailed usage of any command.
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
export function helpCommand(): Command {
|
|
56
|
+
return new Command('help')
|
|
57
|
+
.description('Show detailed help and list all available commands')
|
|
58
|
+
.argument('[command]', 'Show help for a specific command')
|
|
59
|
+
.allowExcessArguments(true)
|
|
60
|
+
.action(async (commandName, _opts, cmd) => {
|
|
61
|
+
if (commandName) {
|
|
62
|
+
// Delegate to the specific command's --help
|
|
63
|
+
const root = cmd.parent;
|
|
64
|
+
if (root) {
|
|
65
|
+
const sub = root.commands.find(
|
|
66
|
+
(c: Command) => c.name() === commandName,
|
|
67
|
+
);
|
|
68
|
+
if (sub) {
|
|
69
|
+
sub.outputHelp();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
console.error(` Unknown command: ${commandName}\n`);
|
|
74
|
+
console.error(` Run crmy help to see all available commands.`);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log(HELP_TEXT);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { getClient } from '../client.js';
|
|
6
|
+
|
|
7
|
+
export function hitlCommand(): Command {
|
|
8
|
+
const cmd = new Command('hitl').description('Manage HITL approval requests');
|
|
9
|
+
|
|
10
|
+
cmd.command('list')
|
|
11
|
+
.action(async () => {
|
|
12
|
+
const client = await getClient();
|
|
13
|
+
const result = await client.call('hitl_list_pending', { limit: 20 });
|
|
14
|
+
const data = JSON.parse(result);
|
|
15
|
+
if (data.requests?.length === 0) {
|
|
16
|
+
console.log('No pending HITL requests.');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
console.table(data.requests.map((r: Record<string, unknown>) => ({
|
|
20
|
+
id: (r.id as string).slice(0, 8),
|
|
21
|
+
type: r.action_type,
|
|
22
|
+
summary: r.action_summary,
|
|
23
|
+
created: r.created_at,
|
|
24
|
+
})));
|
|
25
|
+
await client.close();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
cmd.command('approve <id>')
|
|
29
|
+
.action(async (id) => {
|
|
30
|
+
const client = await getClient();
|
|
31
|
+
const result = await client.call('hitl_resolve', { request_id: id, decision: 'approved' });
|
|
32
|
+
console.log('Approved:', JSON.parse(result).request.id);
|
|
33
|
+
await client.close();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
cmd.command('reject <id>')
|
|
37
|
+
.option('--note <note>', 'Rejection note')
|
|
38
|
+
.action(async (id, opts) => {
|
|
39
|
+
const client = await getClient();
|
|
40
|
+
const result = await client.call('hitl_resolve', {
|
|
41
|
+
request_id: id,
|
|
42
|
+
decision: 'rejected',
|
|
43
|
+
note: opts.note,
|
|
44
|
+
});
|
|
45
|
+
console.log('Rejected:', JSON.parse(result).request.id);
|
|
46
|
+
await client.close();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return cmd;
|
|
50
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
export function initCommand(): Command {
|
|
10
|
+
return new Command('init')
|
|
11
|
+
.description('Initialize crmy.ai: configure database, run migrations, create user')
|
|
12
|
+
.action(async () => {
|
|
13
|
+
const { default: inquirer } = await import('inquirer');
|
|
14
|
+
|
|
15
|
+
console.log('\n crmy.ai — Agent-first CRM setup\n');
|
|
16
|
+
|
|
17
|
+
const answers = await inquirer.prompt([
|
|
18
|
+
{
|
|
19
|
+
type: 'input',
|
|
20
|
+
name: 'databaseUrl',
|
|
21
|
+
message: 'PostgreSQL URL?',
|
|
22
|
+
default: 'postgresql://localhost:5432/crmy',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
type: 'input',
|
|
26
|
+
name: 'name',
|
|
27
|
+
message: 'Your name?',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: 'input',
|
|
31
|
+
name: 'email',
|
|
32
|
+
message: 'Your email?',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
type: 'password',
|
|
36
|
+
name: 'password',
|
|
37
|
+
message: 'Password?',
|
|
38
|
+
mask: '*',
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
process.env.CRMY_IMPORTED = '1';
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Import server modules
|
|
46
|
+
const { initPool, closePool } = await import('@crmy/server');
|
|
47
|
+
const { runMigrations } = await import('@crmy/server');
|
|
48
|
+
|
|
49
|
+
console.log('\nConnecting to database...');
|
|
50
|
+
const db = await initPool(answers.databaseUrl);
|
|
51
|
+
console.log('Connected.');
|
|
52
|
+
|
|
53
|
+
console.log('Running migrations...');
|
|
54
|
+
const ran = await runMigrations(db);
|
|
55
|
+
if (ran.length > 0) {
|
|
56
|
+
console.log(` Ran ${ran.length} migration(s): ${ran.join(', ')}`);
|
|
57
|
+
} else {
|
|
58
|
+
console.log(' No pending migrations.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Seed tenant
|
|
62
|
+
const tenantResult = await db.query(
|
|
63
|
+
`INSERT INTO tenants (slug, name) VALUES ('default', 'Default Tenant')
|
|
64
|
+
ON CONFLICT (slug) DO UPDATE SET name = 'Default Tenant'
|
|
65
|
+
RETURNING id`,
|
|
66
|
+
);
|
|
67
|
+
const tenantId = tenantResult.rows[0].id;
|
|
68
|
+
|
|
69
|
+
// Create user
|
|
70
|
+
const passwordHash = crypto.createHash('sha256').update(answers.password).digest('hex');
|
|
71
|
+
const userResult = await db.query(
|
|
72
|
+
`INSERT INTO users (tenant_id, email, name, role, password_hash)
|
|
73
|
+
VALUES ($1, $2, $3, 'owner', $4)
|
|
74
|
+
ON CONFLICT (tenant_id, email) DO UPDATE SET name = $3, password_hash = $4
|
|
75
|
+
RETURNING id`,
|
|
76
|
+
[tenantId, answers.email, answers.name, passwordHash],
|
|
77
|
+
);
|
|
78
|
+
const userId = userResult.rows[0].id;
|
|
79
|
+
|
|
80
|
+
// Generate API key
|
|
81
|
+
const rawKey = 'crmy_' + crypto.randomBytes(32).toString('hex');
|
|
82
|
+
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
|
83
|
+
await db.query(
|
|
84
|
+
`INSERT INTO api_keys (tenant_id, user_id, key_hash, label, scopes)
|
|
85
|
+
VALUES ($1, $2, $3, 'default', '{read,write,admin}')`,
|
|
86
|
+
[tenantId, userId, keyHash],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Generate JWT secret
|
|
90
|
+
const jwtSecret = crypto.randomBytes(32).toString('hex');
|
|
91
|
+
|
|
92
|
+
// Write config
|
|
93
|
+
const config = {
|
|
94
|
+
serverUrl: 'http://localhost:3000',
|
|
95
|
+
apiKey: rawKey,
|
|
96
|
+
tenantId: 'default',
|
|
97
|
+
database: {
|
|
98
|
+
url: answers.databaseUrl,
|
|
99
|
+
},
|
|
100
|
+
jwtSecret,
|
|
101
|
+
hitl: {
|
|
102
|
+
requireApproval: ['bulk_update', 'bulk_delete', 'send_email'],
|
|
103
|
+
autoApproveSeconds: 0,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const configPath = path.join(process.cwd(), '.crmy.json');
|
|
108
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
109
|
+
|
|
110
|
+
// Add to .gitignore
|
|
111
|
+
const gitignorePath = path.join(process.cwd(), '.gitignore');
|
|
112
|
+
const gitignoreContent = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : '';
|
|
113
|
+
if (!gitignoreContent.includes('.crmy.json')) {
|
|
114
|
+
fs.appendFileSync(gitignorePath, '\n.crmy.json\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await closePool();
|
|
118
|
+
|
|
119
|
+
console.log('\n ✓ crmy.ai initialized\n');
|
|
120
|
+
console.log(' Add to Claude Code:');
|
|
121
|
+
console.log(' claude mcp add crmy -- npx crmy mcp\n');
|
|
122
|
+
console.log(' Or start the server:');
|
|
123
|
+
console.log(' npx crmy server\n');
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error('\nSetup failed:', err instanceof Error ? err.message : err);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|