@plosson/agentio 0.3.2 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plosson/agentio",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,6 +46,7 @@
46
46
  "typescript": "^5.9.3"
47
47
  },
48
48
  "dependencies": {
49
+ "@inquirer/prompts": "^8.2.0",
49
50
  "commander": "^14.0.2",
50
51
  "googleapis": "^169.0.0",
51
52
  "libsodium-wrappers": "^0.8.1",
package/src/auth/oauth.ts CHANGED
@@ -17,13 +17,35 @@ const GCHAT_SCOPES = [
17
17
  'https://www.googleapis.com/auth/userinfo.email', // get user email for profile naming
18
18
  ];
19
19
 
20
- const SCOPES: Record<'gmail' | 'gchat', string[]> = {
20
+ const GDOCS_SCOPES = [
21
+ 'https://www.googleapis.com/auth/documents', // read/write docs
22
+ 'https://www.googleapis.com/auth/drive.file', // create/access files created by this app
23
+ 'https://www.googleapis.com/auth/drive.readonly', // read all drive files (list, metadata, export)
24
+ 'https://www.googleapis.com/auth/userinfo.email', // get email for profile naming
25
+ ];
26
+
27
+ const GDRIVE_READONLY_SCOPES = [
28
+ 'https://www.googleapis.com/auth/drive.readonly', // read all drive files (list, metadata, download)
29
+ 'https://www.googleapis.com/auth/userinfo.email', // get email for profile naming
30
+ ];
31
+
32
+ const GDRIVE_FULL_SCOPES = [
33
+ 'https://www.googleapis.com/auth/drive', // full access to all drive files
34
+ 'https://www.googleapis.com/auth/userinfo.email', // get email for profile naming
35
+ ];
36
+
37
+ const SCOPES: Record<'gmail' | 'gchat' | 'gdocs' | 'gdrive-readonly' | 'gdrive-full', string[]> = {
21
38
  gmail: GMAIL_SCOPES,
22
39
  gchat: GCHAT_SCOPES,
40
+ gdocs: GDOCS_SCOPES,
41
+ 'gdrive-readonly': GDRIVE_READONLY_SCOPES,
42
+ 'gdrive-full': GDRIVE_FULL_SCOPES,
23
43
  };
24
44
 
45
+ export type OAuthService = 'gmail' | 'gchat' | 'gdocs' | 'gdrive-readonly' | 'gdrive-full';
46
+
25
47
  export async function performOAuthFlow(
26
- service: 'gmail' | 'gchat'
48
+ service: OAuthService
27
49
  ): Promise<OAuthTokens> {
28
50
  const port = await findAvailablePort();
29
51
  const redirectUri = `http://localhost:${port}/callback`;
@@ -1,6 +1,6 @@
1
1
  import { google } from 'googleapis';
2
2
  import { getCredentials, setCredentials } from './token-store';
3
- import { getProfile } from '../config/config-manager';
3
+ import { resolveProfile } from '../config/config-manager';
4
4
  import { GOOGLE_OAUTH_CONFIG } from '../config/credentials';
5
5
  import { CliError } from '../utils/errors';
6
6
  import type { ServiceName } from '../types/config';
@@ -10,16 +10,18 @@ const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
10
10
 
11
11
  export async function getValidTokens(
12
12
  service: ServiceName,
13
- profileName: string
13
+ profileName?: string
14
14
  ): Promise<{ tokens: OAuthTokens; profile: string }> {
15
- const profile = await getProfile(service, profileName);
15
+ const { profile, error } = await resolveProfile(service, profileName);
16
16
 
17
17
  if (!profile) {
18
- throw new CliError(
19
- 'PROFILE_NOT_FOUND',
20
- `Profile "${profileName}" not found for ${service}`,
21
- `Run: agentio ${service} profile add`
22
- );
18
+ if (error === 'none') {
19
+ throw new CliError('PROFILE_NOT_FOUND', `No ${service} profile configured`, `Run: agentio ${service} profile add`);
20
+ }
21
+ if (error === 'multiple') {
22
+ throw new CliError('PROFILE_NOT_FOUND', `Multiple ${service} profiles exist`, 'Specify --profile <name>');
23
+ }
24
+ throw new CliError('PROFILE_NOT_FOUND', `Profile "${profileName}" not found for ${service}`, `Run: agentio ${service} profile add`);
23
25
  }
24
26
 
25
27
  const tokens = await getCredentials<OAuthTokens>(service, profile);
@@ -7,9 +7,15 @@ import { loadConfig, saveConfig, setEnv, unsetEnv, listEnv } from '../config/con
7
7
  import { getAllCredentials, setAllCredentials } from '../auth/token-store';
8
8
  import { CliError, handleError } from '../utils/errors';
9
9
  import { confirm } from '../utils/stdin';
10
- import type { Config } from '../types/config';
10
+ import { isInteractive, interactiveCheckbox, interactiveSelect } from '../utils/interactive';
11
+ import type { Config, ServiceName } from '../types/config';
11
12
  import type { StoredCredentials } from '../types/tokens';
12
13
 
14
+ interface ProfileSelection {
15
+ service: ServiceName;
16
+ profile: string;
17
+ }
18
+
13
19
  const ALGORITHM = 'aes-256-gcm';
14
20
 
15
21
  interface ExportedData {
@@ -94,6 +100,7 @@ export function registerConfigCommands(program: Command): void {
94
100
  .description('Export configuration and credentials (as environment variables by default, or to a file)')
95
101
  .option('--key <key>', 'Encryption key (64 hex characters). If not provided, a random key will be generated')
96
102
  .option('--file <path>', 'Write encrypted config to file instead of outputting AGENTIO_CONFIG')
103
+ .option('--all', 'Export all profiles without prompting for selection')
97
104
  .action(async (options) => {
98
105
  try {
99
106
  // Validate key if provided
@@ -115,23 +122,103 @@ export function registerConfigCommands(program: Command): void {
115
122
  const configData = await loadConfig();
116
123
  const credentials = await getAllCredentials();
117
124
 
125
+ // Build list of all available profiles
126
+ const allProfiles: ProfileSelection[] = [];
127
+ for (const [service, profiles] of Object.entries(configData.profiles)) {
128
+ if (profiles) {
129
+ for (const profile of profiles) {
130
+ allProfiles.push({ service: service as ServiceName, profile });
131
+ }
132
+ }
133
+ }
134
+
135
+ if (allProfiles.length === 0) {
136
+ throw new CliError(
137
+ 'NOT_FOUND',
138
+ 'No profiles configured',
139
+ 'Add profiles first with: agentio <service> profile add'
140
+ );
141
+ }
142
+
143
+ // Determine which profiles to export
144
+ let selectedProfiles: ProfileSelection[];
145
+
146
+ if (options.all || !isInteractive()) {
147
+ // Export all profiles
148
+ selectedProfiles = allProfiles;
149
+ } else {
150
+ // Interactive: ask user to select profiles
151
+ const exportAll = await interactiveSelect({
152
+ message: 'What would you like to export?',
153
+ choices: [
154
+ { name: `All profiles (${allProfiles.length})`, value: 'all' },
155
+ { name: 'Select specific profiles', value: 'select' },
156
+ ],
157
+ default: 'all',
158
+ });
159
+
160
+ if (exportAll === 'all') {
161
+ selectedProfiles = allProfiles;
162
+ } else {
163
+ selectedProfiles = await interactiveCheckbox({
164
+ message: 'Select profiles to export:',
165
+ choices: allProfiles.map((p) => ({
166
+ name: `${p.service}: ${p.profile}`,
167
+ value: p,
168
+ checked: false,
169
+ })),
170
+ required: true,
171
+ });
172
+ }
173
+ }
174
+
175
+ // Filter config and credentials based on selection
176
+ const filteredConfig: Config = { profiles: {} };
177
+ const filteredCredentials: StoredCredentials = {};
178
+
179
+ for (const { service, profile } of selectedProfiles) {
180
+ // Add to filtered config
181
+ if (!filteredConfig.profiles[service]) {
182
+ (filteredConfig.profiles as Record<string, string[]>)[service] = [];
183
+ }
184
+ (filteredConfig.profiles as Record<string, string[]>)[service].push(profile);
185
+
186
+ // Add credentials if they exist
187
+ if (credentials[service]?.[profile]) {
188
+ if (!filteredCredentials[service]) {
189
+ filteredCredentials[service] = {};
190
+ }
191
+ filteredCredentials[service][profile] = credentials[service][profile];
192
+ }
193
+ }
194
+
195
+ // Include env vars if they exist
196
+ if (configData.env) {
197
+ filteredConfig.env = configData.env;
198
+ }
199
+
118
200
  const exportData: ExportedData = {
119
201
  version: 1,
120
- config: configData,
121
- credentials,
202
+ config: filteredConfig,
203
+ credentials: filteredCredentials,
122
204
  };
123
205
 
124
206
  // Encrypt the data
125
207
  const key = deriveKeyFromPassword(encryptionKey);
126
208
  const encrypted = encrypt(JSON.stringify(exportData), key);
127
209
 
210
+ const profileCount = selectedProfiles.length;
211
+ const profileText = profileCount === 1 ? 'profile' : 'profiles';
212
+
128
213
  if (options.file) {
129
214
  // Write to file, output just the key
130
215
  const filePath = options.file.startsWith('/') ? options.file : join(process.cwd(), options.file);
131
216
  await writeFile(filePath, encrypted, { mode: 0o600 });
217
+ console.error(`Exported ${profileCount} ${profileText} to ${filePath}`);
132
218
  console.log(`AGENTIO_KEY=${encryptionKey}`);
133
219
  } else {
134
220
  // Output as environment variables
221
+ console.error(`Exported ${profileCount} ${profileText}`);
135
222
  console.log(`AGENTIO_KEY=${encryptionKey}`);
136
223
  console.log(`AGENTIO_CONFIG=${encrypted}`);
137
224
  }
@@ -25,7 +25,7 @@ export function registerDiscourseCommands(program: Command): void {
25
25
  discourse
26
26
  .command('list')
27
27
  .description('List latest topics')
28
- .requiredOption('--profile <name>', 'Profile name')
28
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
29
29
  .option('--category <slug>', 'Filter by category slug or name')
30
30
  .option('--page <number>', 'Page number (0-indexed)', '0')
31
31
  .action(async (options) => {
@@ -46,7 +46,7 @@ export function registerDiscourseCommands(program: Command): void {
46
46
  .command('get')
47
47
  .description('Get a topic with its posts')
48
48
  .argument('<topic-id>', 'Topic ID')
49
- .requiredOption('--profile <name>', 'Profile name')
49
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
50
50
  .action(async (topicId: string, options) => {
51
51
  try {
52
52
  const id = parseInt(topicId, 10);
@@ -66,7 +66,7 @@ export function registerDiscourseCommands(program: Command): void {
66
66
  discourse
67
67
  .command('categories')
68
68
  .description('List all categories')
69
- .requiredOption('--profile <name>', 'Profile name')
69
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
70
70
  .action(async (options) => {
71
71
  try {
72
72
  const { client } = await getDiscourseClient(options.profile);
@@ -10,6 +10,7 @@ import { createGoogleAuth } from '../auth/token-manager';
10
10
  import { GChatClient } from '../services/gchat/client';
11
11
  import { CliError, handleError } from '../utils/errors';
12
12
  import { readStdin, prompt } from '../utils/stdin';
13
+ import { interactiveSelect } from '../utils/interactive';
13
14
  import { printGChatSendResult, printGChatMessageList, printGChatMessage, printGChatSpaceList } from '../utils/output';
14
15
  import type { GChatCredentials, GChatWebhookCredentials, GChatOAuthCredentials } from '../types/gchat';
15
16
 
@@ -26,7 +27,7 @@ export function registerGChatCommands(program: Command): void {
26
27
  gchat
27
28
  .command('send')
28
29
  .description('Send a message to Google Chat')
29
- .requiredOption('--profile <name>', 'Profile name')
30
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
30
31
  .option('--space <id>', 'Space ID (required for OAuth profiles)')
31
32
  .option('--thread <id>', 'Thread ID (optional)')
32
33
  .option('--json [file]', 'Send rich message from JSON file (or stdin if no file specified)')
@@ -111,7 +112,7 @@ export function registerGChatCommands(program: Command): void {
111
112
  gchat
112
113
  .command('list')
113
114
  .description('List messages from a Google Chat space (OAuth profiles only)')
114
- .requiredOption('--profile <name>', 'Profile name')
115
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
115
116
  .requiredOption('--space <id>', 'Space ID')
116
117
  .option('--limit <n>', 'Number of messages', '10')
117
118
  .option('--thread <id>', 'Filter by thread ID')
@@ -135,7 +136,7 @@ export function registerGChatCommands(program: Command): void {
135
136
  gchat
136
137
  .command('get <message-id>')
137
138
  .description('Get a message from a Google Chat space (OAuth profiles only)')
138
- .requiredOption('--profile <name>', 'Profile name')
139
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
139
140
  .requiredOption('--space <id>', 'Space ID')
140
141
  .action(async (messageId: string, options) => {
141
142
  try {
@@ -154,7 +155,7 @@ export function registerGChatCommands(program: Command): void {
154
155
  gchat
155
156
  .command('spaces')
156
157
  .description('List available Google Chat spaces (OAuth profiles only)')
157
- .requiredOption('--profile <name>', 'Profile name')
158
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
158
159
  .option('--filter <text>', 'Filter spaces by name (case-insensitive)')
159
160
  .action(async (options) => {
160
161
  try {
@@ -187,9 +188,15 @@ export function registerGChatCommands(program: Command): void {
187
188
  try {
188
189
  console.error('\nGoogle Chat Setup\n');
189
190
 
190
- const profileType = await prompt('Choose profile type (webhook/oauth): ');
191
+ const profileType = await interactiveSelect({
192
+ message: 'Choose profile type:',
193
+ choices: [
194
+ { name: 'Webhook', value: 'webhook', description: 'Simple incoming webhook URL' },
195
+ { name: 'OAuth', value: 'oauth', description: 'Full API access with Google Workspace account' },
196
+ ],
197
+ });
191
198
 
192
- if (profileType.toLowerCase() === 'webhook') {
199
+ if (profileType === 'webhook') {
193
200
  if (!options.profile) {
194
201
  throw new CliError(
195
202
  'INVALID_PARAMS',
@@ -198,10 +205,8 @@ export function registerGChatCommands(program: Command): void {
198
205
  );
199
206
  }
200
207
  await setupWebhookProfile(options.profile);
201
- } else if (profileType.toLowerCase() === 'oauth') {
202
- await setupOAuthProfile(options.profile);
203
208
  } else {
204
- throw new CliError('INVALID_PARAMS', 'Profile type must be "webhook" or "oauth"');
209
+ await setupOAuthProfile(options.profile);
205
210
  }
206
211
  } catch (error) {
207
212
  handleError(error);
@@ -0,0 +1,186 @@
1
+ import { Command } from 'commander';
2
+ import { writeFile } from 'fs/promises';
3
+ import { google } from 'googleapis';
4
+ import { createGoogleAuth } from '../auth/token-manager';
5
+ import { setCredentials } from '../auth/token-store';
6
+ import { setProfile } from '../config/config-manager';
7
+ import { createProfileCommands } from '../utils/profile-commands';
8
+ import { createClientGetter } from '../utils/client-factory';
9
+ import { performOAuthFlow } from '../auth/oauth';
10
+ import { GDocsClient } from '../services/gdocs/client';
11
+ import { printGDocsList, printGDocCreated, raw } from '../utils/output';
12
+ import { CliError, handleError } from '../utils/errors';
13
+ import { readStdin } from '../utils/stdin';
14
+ import type { GDocsCredentials } from '../types/gdocs';
15
+
16
+ const getGDocsClient = createClientGetter<GDocsCredentials, GDocsClient>({
17
+ service: 'gdocs',
18
+ createClient: (credentials) => new GDocsClient(credentials),
19
+ });
20
+
21
+ export function registerGDocsCommands(program: Command): void {
22
+ const gdocs = program
23
+ .command('gdocs')
24
+ .description('Google Docs operations');
25
+
26
+ gdocs
27
+ .command('get <doc-id-or-url>')
28
+ .description('Export a document')
29
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
30
+ .option('--format <format>', 'Export format: markdown or docx', 'markdown')
31
+ .option('--output <file>', 'Output file path (required for docx, optional for markdown)')
32
+ .action(async (docIdOrUrl: string, options) => {
33
+ try {
34
+ const { client } = await getGDocsClient(options.profile);
35
+ const format = options.format.toLowerCase();
36
+
37
+ if (format !== 'markdown' && format !== 'docx') {
38
+ throw new CliError('INVALID_PARAMS', `Unknown format: ${format}`, 'Use --format markdown or --format docx');
39
+ }
40
+
41
+ if (format === 'docx' && !options.output) {
42
+ throw new CliError('INVALID_PARAMS', 'Output file required for docx format', 'Use --output <file.docx>');
43
+ }
44
+
45
+ const content = format === 'docx'
46
+ ? await client.getAsDocx(docIdOrUrl)
47
+ : await client.getAsMarkdown(docIdOrUrl);
48
+
49
+ if (options.output) {
50
+ await writeFile(options.output, content);
51
+ console.log(`Exported to ${options.output}`);
52
+ } else {
53
+ raw(content as string);
54
+ }
55
+ } catch (error) {
56
+ handleError(error);
57
+ }
58
+ });
59
+
60
+ gdocs
61
+ .command('create')
62
+ .description('Create a new document from Markdown')
63
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
64
+ .requiredOption('--title <title>', 'Document title')
65
+ .option('--content <text>', 'Markdown content (or pipe via stdin)')
66
+ .option('--folder <folder-id>', 'Folder ID to create the document in')
67
+ .action(async (options) => {
68
+ try {
69
+ let content = options.content;
70
+
71
+ if (!content) {
72
+ content = await readStdin();
73
+ }
74
+
75
+ if (!content) {
76
+ throw new CliError('INVALID_PARAMS', 'No content provided', 'Provide --content or pipe markdown via stdin');
77
+ }
78
+
79
+ const { client } = await getGDocsClient(options.profile);
80
+ const result = await client.create(options.title, content, options.folder);
81
+
82
+ printGDocCreated(result);
83
+ } catch (error) {
84
+ handleError(error);
85
+ }
86
+ });
87
+
88
+ gdocs
89
+ .command('list')
90
+ .description('List recent documents')
91
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
92
+ .option('--limit <n>', 'Number of documents', '10')
93
+ .option('--query <query>', 'Drive search query filter')
94
+ .addHelpText('after', `
95
+ Query Syntax Examples:
96
+
97
+ Name search:
98
+ --query "name contains 'project'" Documents with "project" in name
99
+ --query "name = 'Meeting Notes'" Exact name match
100
+
101
+ Ownership:
102
+ --query "'me' in owners" Documents you own
103
+ --query "'user@example.com' in owners" Documents owned by specific user
104
+
105
+ Date filters:
106
+ --query "modifiedTime > '2024-01-01'" Modified after date
107
+ --query "createdTime > '2024-01-01'" Created after date
108
+
109
+ Starred/Trashed:
110
+ --query "starred = true" Starred documents
111
+ --query "trashed = false" Not in trash (default)
112
+
113
+ Combined:
114
+ --query "name contains 'report' and modifiedTime > '2024-01-01'"
115
+ `)
116
+ .action(async (options) => {
117
+ try {
118
+ const { client } = await getGDocsClient(options.profile);
119
+ const docs = await client.list({
120
+ limit: parseInt(options.limit, 10),
121
+ query: options.query,
122
+ });
123
+ printGDocsList(docs);
124
+ } catch (error) {
125
+ handleError(error);
126
+ }
127
+ });
128
+
129
+ // Profile management
130
+ const profile = createProfileCommands<GDocsCredentials>(gdocs, {
131
+ service: 'gdocs',
132
+ displayName: 'Google Docs',
133
+ getExtraInfo: (credentials) => credentials?.email ? ` - ${credentials.email}` : '',
134
+ });
135
+
136
+ profile
137
+ .command('add')
138
+ .description('Add a new Google Docs profile')
139
+ .option('--profile <name>', 'Profile name (auto-detected from email if not provided)')
140
+ .action(async (options) => {
141
+ try {
142
+ console.error('Starting OAuth flow for Google Docs...\n');
143
+
144
+ const tokens = await performOAuthFlow('gdocs');
145
+ const auth = createGoogleAuth(tokens);
146
+
147
+ // Fetch user email for profile naming
148
+ let userEmail: string;
149
+ try {
150
+ const oauth2 = google.oauth2({ version: 'v2', auth });
151
+ const userInfo = await oauth2.userinfo.get();
152
+ userEmail = userInfo.data.email || '';
153
+ if (!userEmail) {
154
+ throw new Error('No email returned');
155
+ }
156
+ } catch (error) {
157
+ const errorMessage = error instanceof Error ? error.message : String(error);
158
+ throw new CliError(
159
+ 'AUTH_FAILED',
160
+ `Failed to fetch user email: ${errorMessage}`,
161
+ 'Ensure the account has an email address'
162
+ );
163
+ }
164
+
165
+ const profileName = options.profile || userEmail;
166
+
167
+ const credentials: GDocsCredentials = {
168
+ accessToken: tokens.access_token,
169
+ refreshToken: tokens.refresh_token,
170
+ expiryDate: tokens.expiry_date,
171
+ tokenType: tokens.token_type,
172
+ scope: tokens.scope,
173
+ email: userEmail,
174
+ };
175
+
176
+ await setProfile('gdocs', profileName);
177
+ await setCredentials('gdocs', profileName, credentials);
178
+
179
+ console.log(`\nSuccess! Profile "${profileName}" configured.`);
180
+ console.log(` Email: ${userEmail}`);
181
+ console.log(` Test with: agentio gdocs list --profile ${profileName}`);
182
+ } catch (error) {
183
+ handleError(error);
184
+ }
185
+ });
186
+ }