@krunal202/apple-search-ads-cli 0.1.0

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.
@@ -0,0 +1,92 @@
1
+ import { Command } from 'commander';
2
+ import { api, apiAll } from '../api/client.js';
3
+ import { AsaError } from '../errors.js';
4
+ import { print } from '../output.js';
5
+ import { mergeData, money } from './helpers.js';
6
+
7
+ const base = (campaignId) => `/campaigns/${campaignId}/adgroups`;
8
+
9
+ function requireCampaign(opts) {
10
+ if (!opts.campaign) {
11
+ throw new AsaError('--campaign is required (API v5 scopes ad groups under campaigns)', {
12
+ hint: 'Find the campaign ID with "asa campaigns list".',
13
+ });
14
+ }
15
+ return opts.campaign;
16
+ }
17
+
18
+ function stripUndefined(obj) {
19
+ for (const key of Object.keys(obj)) if (obj[key] === undefined) delete obj[key];
20
+ return obj;
21
+ }
22
+
23
+ export function registerAdGroups(program) {
24
+ const cmd = new Command('adgroups').description('Manage ad groups (scoped to a campaign)');
25
+
26
+ cmd.command('list').description('List ad groups in a campaign')
27
+ .requiredOption('--campaign <id>', 'campaign ID')
28
+ .option('--limit <n>', 'max results (1-1000)', '20')
29
+ .option('--offset <n>', 'result offset', '0')
30
+ .option('--all', 'fetch every page')
31
+ .action(async (opts) => {
32
+ if (opts.all) return print({ data: await apiAll(base(requireCampaign(opts))), error: null });
33
+ print(await api('GET', base(requireCampaign(opts)), { query: { limit: opts.limit, offset: opts.offset } }));
34
+ });
35
+
36
+ cmd.command('get').description('Get a single ad group')
37
+ .argument('<id>', 'ad group ID')
38
+ .requiredOption('--campaign <id>', 'campaign ID')
39
+ .action(async (id, opts) => print(await api('GET', `${base(requireCampaign(opts))}/${id}`)));
40
+
41
+ cmd.command('create').description('Create an ad group')
42
+ .requiredOption('--campaign <id>', 'parent campaign ID')
43
+ .requiredOption('--name <name>', 'ad group name')
44
+ .option('--bid <amount>', 'default CPT bid amount')
45
+ .option('--currency <code>', 'currency (required with --bid)')
46
+ .option('--status <status>', 'ENABLED or PAUSED', 'ENABLED')
47
+ .option('--start-time <iso>', 'start time (defaults to now)')
48
+ .option('--data <json>', 'raw JSON body, merged over the flag-built body; use for targeting dimensions, CPA goal, etc.')
49
+ .action(async (opts) => {
50
+ if (opts.bid && !opts.currency && !opts.data) {
51
+ throw new AsaError('--currency is required when setting --bid');
52
+ }
53
+ const body = mergeData(stripUndefined({
54
+ name: opts.name,
55
+ defaultBidAmount: money(opts.bid, opts.currency),
56
+ pricingModel: 'CPC',
57
+ status: opts.status,
58
+ startTime: opts.startTime ?? new Date().toISOString(),
59
+ }), opts.data);
60
+ print(await api('POST', base(requireCampaign(opts)), { body }));
61
+ });
62
+
63
+ cmd.command('update').description('Update an ad group')
64
+ .argument('<id>', 'ad group ID')
65
+ .requiredOption('--campaign <id>', 'campaign ID')
66
+ .option('--name <name>')
67
+ .option('--status <status>', 'ENABLED or PAUSED')
68
+ .option('--bid <amount>', 'default CPT bid amount')
69
+ .option('--currency <code>', 'currency (required with --bid)')
70
+ .option('--data <json>', 'raw JSON body, merged over the flag-built body')
71
+ .action(async (id, opts) => {
72
+ if (opts.bid && !opts.currency && !opts.data) {
73
+ throw new AsaError('--currency is required when setting --bid');
74
+ }
75
+ const body = mergeData(stripUndefined({
76
+ name: opts.name,
77
+ status: opts.status,
78
+ defaultBidAmount: money(opts.bid, opts.currency),
79
+ }), opts.data);
80
+ print(await api('PUT', `${base(requireCampaign(opts))}/${id}`, { body }));
81
+ });
82
+
83
+ cmd.command('delete').description('Delete an ad group')
84
+ .argument('<id>', 'ad group ID')
85
+ .requiredOption('--campaign <id>', 'campaign ID')
86
+ .action(async (id, opts) => {
87
+ await api('DELETE', `${base(requireCampaign(opts))}/${id}`);
88
+ print({ ok: true, deleted: Number(id) });
89
+ });
90
+
91
+ program.addCommand(cmd);
92
+ }
@@ -0,0 +1,16 @@
1
+ import { Command } from 'commander';
2
+ import { api } from '../api/client.js';
3
+ import { print } from '../output.js';
4
+
5
+ export function registerApps(program) {
6
+ const cmd = new Command('apps').description('Look up App Store apps');
7
+
8
+ cmd.command('search').description('Search apps by name to find the adamId needed for campaign creation')
9
+ .argument('<query>', 'app name, e.g. "secure vpn"')
10
+ .option('--limit <n>', 'max results', '10')
11
+ .action(async (query, opts) => {
12
+ print(await api('GET', '/search/apps', { query: { query, limit: opts.limit } }));
13
+ });
14
+
15
+ program.addCommand(cmd);
16
+ }
@@ -0,0 +1,108 @@
1
+ import fs from 'node:fs';
2
+ import readline from 'node:readline/promises';
3
+ import { Command } from 'commander';
4
+ import { api } from '../api/client.js';
5
+ import { readJson, remove, writeJson } from '../config.js';
6
+ import { AsaError } from '../errors.js';
7
+ import { print } from '../output.js';
8
+
9
+ async function ask(question, fallback) {
10
+ if (!process.stdin.isTTY) {
11
+ if (fallback !== undefined) return fallback;
12
+ throw new AsaError(`Missing required value: ${question}`, {
13
+ hint: 'Non-interactive shell detected. Pass all values as flags: asa login --client-id <id> --team-id <id> --key-id <id> --key-file <path-to-p8> [--org <orgId>]',
14
+ });
15
+ }
16
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
17
+ try {
18
+ const answer = await rl.question(`${question}: `);
19
+ return answer.trim() || fallback;
20
+ } finally {
21
+ rl.close();
22
+ }
23
+ }
24
+
25
+ async function login(opts) {
26
+ const clientId = await ask('Client ID', opts.clientId);
27
+ const teamId = await ask('Team ID', opts.teamId);
28
+ const keyId = await ask('Key ID', opts.keyId);
29
+ const keyFile = await ask('Path to .p8 private key file', opts.keyFile);
30
+
31
+ if (!clientId || !teamId || !keyId || !keyFile) {
32
+ throw new AsaError('client ID, team ID, key ID and key file are all required', {
33
+ hint: 'Create credentials in Apple Search Ads → Account Settings → API. See "asa llm" for details.',
34
+ });
35
+ }
36
+
37
+ let privateKey;
38
+ try {
39
+ privateKey = fs.readFileSync(keyFile, 'utf8');
40
+ } catch {
41
+ throw new AsaError(`Cannot read private key file: ${keyFile}`);
42
+ }
43
+
44
+ writeJson('config.json', { clientId, teamId, keyId, privateKey, orgId: opts.org ? Number(opts.org) : undefined });
45
+ remove('token.json');
46
+
47
+ const acls = await api('GET', '/acls');
48
+ const orgs = (acls.data ?? acls).map((acl) => ({ orgId: acl.orgId, orgName: acl.orgName, role: acl.roleName ?? acl.roleNames }));
49
+
50
+ let orgId = opts.org ? Number(opts.org) : undefined;
51
+ if (!orgId) {
52
+ if (orgs.length === 0) throw new AsaError('No organizations found for these credentials');
53
+ orgId = orgs[0].orgId;
54
+ if (orgs.length > 1 && process.stdin.isTTY) {
55
+ const answer = await ask(`Multiple orgs available. Org ID to use [${orgs.map((o) => o.orgId).join(', ')}]`, String(orgId));
56
+ orgId = Number(answer);
57
+ }
58
+ }
59
+
60
+ writeJson('config.json', { clientId, teamId, keyId, privateKey, orgId });
61
+ print({ ok: true, message: 'Logged in', orgId, orgs });
62
+ }
63
+
64
+ function logout() {
65
+ remove('config.json', 'token.json', 'clipboard.json');
66
+ print({ ok: true, message: 'Logged out; credentials, token and clipboard removed' });
67
+ }
68
+
69
+ function whoami() {
70
+ const config = readJson('config.json');
71
+ if (!config) throw new AsaError('Not logged in', { hint: 'Run "asa login" first.' });
72
+ print({ clientId: config.clientId, teamId: config.teamId, keyId: config.keyId, orgId: config.orgId });
73
+ }
74
+
75
+ export function registerAuth(program) {
76
+ program.command('login')
77
+ .description('Store Apple Search Ads API credentials and select an org')
78
+ .option('--client-id <id>', 'API client ID')
79
+ .option('--team-id <id>', 'Apple team ID')
80
+ .option('--key-id <id>', 'API key ID')
81
+ .option('--key-file <path>', 'path to the .p8 private key')
82
+ .option('--org <orgId>', 'organization ID to activate')
83
+ .action(login);
84
+
85
+ program.command('logout').description('Remove stored credentials, token and clipboard').action(logout);
86
+ program.command('whoami').description('Show the stored credentials summary and active org').action(whoami);
87
+ }
88
+
89
+ export function registerOrgs(program) {
90
+ const cmd = new Command('orgs').description('List and switch organizations');
91
+
92
+ cmd.command('list').description('List orgs accessible with the current credentials')
93
+ .action(async () => {
94
+ const acls = await api('GET', '/acls');
95
+ print(acls.data ?? acls);
96
+ });
97
+
98
+ cmd.command('use').description('Set the active organization')
99
+ .argument('<orgId>', 'organization ID from "asa orgs list"')
100
+ .action(async (orgId) => {
101
+ const config = readJson('config.json');
102
+ if (!config) throw new AsaError('Not logged in', { hint: 'Run "asa login" first.' });
103
+ writeJson('config.json', { ...config, orgId: Number(orgId) });
104
+ print({ ok: true, orgId: Number(orgId) });
105
+ });
106
+
107
+ program.addCommand(cmd);
108
+ }
@@ -0,0 +1,90 @@
1
+ import { Command } from 'commander';
2
+ import { api, apiAll } from '../api/client.js';
3
+ import { AsaError } from '../errors.js';
4
+ import { print } from '../output.js';
5
+ import { csv, mergeData, money } from './helpers.js';
6
+
7
+ export function registerCampaigns(program) {
8
+ const cmd = new Command('campaigns').description('Manage campaigns');
9
+
10
+ cmd.command('list').description('List campaigns')
11
+ .option('--limit <n>', 'max results (1-1000)', '20')
12
+ .option('--offset <n>', 'result offset', '0')
13
+ .option('--all', 'fetch every page')
14
+ .action(async (opts) => {
15
+ if (opts.all) return print({ data: await apiAll('/campaigns'), error: null });
16
+ print(await api('GET', '/campaigns', { query: { limit: opts.limit, offset: opts.offset } }));
17
+ });
18
+
19
+ cmd.command('get').description('Get a single campaign')
20
+ .argument('<id>', 'campaign ID')
21
+ .action(async (id) => print(await api('GET', `/campaigns/${id}`)));
22
+
23
+ cmd.command('create').description('Create a campaign (created PAUSED by default)')
24
+ .requiredOption('--name <name>', 'campaign name')
25
+ .requiredOption('--currency <code>', 'ISO currency, e.g. USD')
26
+ .requiredOption('--adam-id <id>', 'App Store app ID (numeric)')
27
+ .requiredOption('--countries <codes>', 'comma-separated storefront codes, e.g. US,GB')
28
+ .option('--budget <amount>', 'lifetime budget amount, e.g. 1000 (optional)')
29
+ .option('--daily-budget <amount>', 'daily budget amount')
30
+ .option('--status <status>', 'ENABLED or PAUSED', 'PAUSED')
31
+ .option('--start-time <iso>', 'start time, e.g. 2026-09-20T00:00:00.000Z')
32
+ .option('--end-time <iso>', 'end time')
33
+ .option('--data <json>', 'raw JSON body, merged over the flag-built body')
34
+ .action(async (opts) => {
35
+ const body = mergeData({
36
+ name: opts.name,
37
+ adamId: Number(opts.adamId),
38
+ countriesOrRegions: csv(opts.countries),
39
+ adChannelType: 'SEARCH',
40
+ supplySources: ['APPSTORE_SEARCH_RESULTS'],
41
+ billingEvent: 'TAPS',
42
+ budgetAmount: money(opts.budget, opts.currency),
43
+ dailyBudgetAmount: money(opts.dailyBudget, opts.currency),
44
+ status: opts.status,
45
+ startTime: opts.startTime,
46
+ endTime: opts.endTime,
47
+ }, opts.data);
48
+ print(await api('POST', '/campaigns', { body }));
49
+ });
50
+
51
+ cmd.command('update').description('Update a campaign')
52
+ .argument('<id>', 'campaign ID')
53
+ .option('--name <name>')
54
+ .option('--status <status>', 'ENABLED or PAUSED')
55
+ .option('--budget <amount>', 'lifetime budget amount')
56
+ .option('--daily-budget <amount>', 'daily budget amount')
57
+ .option('--currency <code>', 'currency (required when setting budgets)')
58
+ .option('--countries <codes>', 'comma-separated storefront codes')
59
+ .option('--start-time <iso>')
60
+ .option('--end-time <iso>')
61
+ .option('--clear-end-time', 'remove the end time')
62
+ .option('--data <json>', 'raw JSON body, merged over the flag-built body')
63
+ .action(async (id, opts) => {
64
+ if ((opts.budget || opts.dailyBudget) && !opts.currency && !opts.data) {
65
+ throw new AsaError('--currency is required when setting budgets');
66
+ }
67
+ const fields = mergeData({
68
+ name: opts.name,
69
+ status: opts.status,
70
+ budgetAmount: money(opts.budget, opts.currency),
71
+ dailyBudgetAmount: money(opts.dailyBudget, opts.currency),
72
+ countriesOrRegions: opts.countries ? csv(opts.countries) : undefined,
73
+ startTime: opts.startTime,
74
+ endTime: opts.clearEndTime ? null : opts.endTime,
75
+ }, opts.data);
76
+ for (const key of Object.keys(fields)) if (fields[key] === undefined) delete fields[key];
77
+ const body = { campaign: fields };
78
+ if (opts.countries) body.clearGeoTargetingOnCountryOrRegionChange = true;
79
+ print(await api('PUT', `/campaigns/${id}`, { body }));
80
+ });
81
+
82
+ cmd.command('delete').description('Delete a campaign')
83
+ .argument('<id>', 'campaign ID')
84
+ .action(async (id) => {
85
+ await api('DELETE', `/campaigns/${id}`);
86
+ print({ ok: true, deleted: Number(id) });
87
+ });
88
+
89
+ program.addCommand(cmd);
90
+ }
@@ -0,0 +1,100 @@
1
+ import { api } from '../api/client.js';
2
+ import { readJson, writeJson } from '../config.js';
3
+ import { AsaError } from '../errors.js';
4
+ import { print } from '../output.js';
5
+
6
+ const TYPES = {
7
+ campaign: {
8
+ fetch: (id) => api('GET', `/campaigns/${id}`),
9
+ create: (data) => api('POST', '/campaigns', { body: data }),
10
+ },
11
+ adgroup: {
12
+ needs: ['campaign'],
13
+ fetch: (id, opts) => api('GET', `/campaigns/${opts.campaign}/adgroups/${id}`),
14
+ create: (data, opts) => api('POST', `/campaigns/${opts.campaign ?? data.campaignId}/adgroups`, { body: data }),
15
+ },
16
+ keyword: {
17
+ needs: ['campaign', 'adgroup'],
18
+ fetch: (id, opts) => api('GET', `/campaigns/${opts.campaign}/adgroups/${opts.adgroup}/targetingkeywords/${id}`),
19
+ create: (data, opts) => api('POST', `/campaigns/${opts.campaign ?? data.campaignId}/adgroups/${opts.adgroup ?? data.adGroupId}/targetingkeywords/bulk`, { body: [data] }),
20
+ },
21
+ };
22
+
23
+ const READ_ONLY_FIELDS = [
24
+ 'id', 'orgId', 'creationTime', 'modificationTime', 'deleted',
25
+ 'servingStatus', 'servingStateReasons', 'displayStatus', 'computedStatus',
26
+ ];
27
+
28
+ export function sanitize(data) {
29
+ if (data && typeof data === 'object' && data.data && typeof data.data === 'object') {
30
+ return sanitize(data.data);
31
+ }
32
+ const copy = { ...data };
33
+ for (const field of READ_ONLY_FIELDS) delete copy[field];
34
+ return copy;
35
+ }
36
+
37
+ async function copyResource(type, id, opts) {
38
+ const handler = TYPES[type];
39
+ if (!handler) {
40
+ throw new AsaError(`Unknown resource type "${type}"`, {
41
+ hint: `Valid types: ${Object.keys(TYPES).join(', ')}. Example: asa copy campaign 123456`,
42
+ });
43
+ }
44
+ for (const required of handler.needs ?? []) {
45
+ if (!opts[required]) {
46
+ throw new AsaError(`--${required} is required when copying a ${type} (API v5 scopes it under its parents)`, {
47
+ hint: `Example: asa copy ${type} ${id} ${(handler.needs ?? []).map((n) => `--${n} <id>`).join(' ')}`,
48
+ });
49
+ }
50
+ }
51
+ const data = sanitize(await handler.fetch(id, opts));
52
+ writeJson('clipboard.json', { type, sourceId: Number(id), parents: { campaign: opts.campaign, adgroup: opts.adgroup }, data });
53
+ print({ ok: true, copied: { type, sourceId: Number(id) }, data });
54
+ }
55
+
56
+ async function pasteResource(opts) {
57
+ const clipboard = readJson('clipboard.json');
58
+ if (!clipboard) {
59
+ throw new AsaError('Clipboard is empty', { hint: 'Copy something first, e.g. asa copy campaign 123456' });
60
+ }
61
+ const handler = TYPES[clipboard.type];
62
+ const data = {
63
+ ...clipboard.data,
64
+ name: opts.name ?? (clipboard.data.name ? `${clipboard.data.name} - Copy` : undefined),
65
+ status: opts.status ?? 'PAUSED',
66
+ };
67
+ for (const key of Object.keys(data)) if (data[key] === undefined) delete data[key];
68
+ const now = Date.now();
69
+ if (data.startTime && new Date(data.startTime).getTime() < now) data.startTime = new Date().toISOString();
70
+ if (data.endTime && new Date(data.endTime).getTime() < now) delete data.endTime;
71
+ const parents = {
72
+ campaign: opts.campaign ?? clipboard.parents?.campaign,
73
+ adgroup: opts.adgroup ?? clipboard.parents?.adgroup,
74
+ };
75
+ if (clipboard.type === 'adgroup' && parents.campaign) data.campaignId = Number(parents.campaign);
76
+ if (clipboard.type === 'keyword') {
77
+ if (parents.campaign) data.campaignId = Number(parents.campaign);
78
+ if (parents.adgroup) data.adGroupId = Number(parents.adgroup);
79
+ }
80
+ const created = await handler.create(data, parents);
81
+ print({ ok: true, pasted: clipboard.type, from: clipboard.sourceId, created });
82
+ }
83
+
84
+ export function registerClipboard(program) {
85
+ program.command('copy')
86
+ .description('Copy a resource to the local clipboard')
87
+ .argument('<type>', `resource type: ${Object.keys(TYPES).join(' | ')}`)
88
+ .argument('<id>', 'resource ID')
89
+ .option('--campaign <id>', 'campaign ID (required for adgroup and keyword)')
90
+ .option('--adgroup <id>', 'ad group ID (required for keyword)')
91
+ .action(copyResource);
92
+
93
+ program.command('paste')
94
+ .description('Create a new resource from the clipboard (created PAUSED unless --status is given)')
95
+ .option('--name <name>', 'override the copied name')
96
+ .option('--status <status>', 'ENABLED or PAUSED', 'PAUSED')
97
+ .option('--campaign <id>', 'target campaign ID when pasting an ad group or keyword (defaults to the source)')
98
+ .option('--adgroup <id>', 'target ad group ID when pasting a keyword (defaults to the source)')
99
+ .action(pasteResource);
100
+ }
@@ -0,0 +1,9 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const guidePath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'LLM_GUIDE.md');
6
+
7
+ export function printGuide() {
8
+ process.stdout.write(fs.readFileSync(guidePath, 'utf8'));
9
+ }
@@ -0,0 +1,27 @@
1
+ import { AsaError } from '../errors.js';
2
+
3
+ export function parseJsonOption(value, label) {
4
+ try {
5
+ return JSON.parse(value);
6
+ } catch {
7
+ throw new AsaError(`Invalid JSON for ${label}`, {
8
+ hint: `Pass valid JSON, e.g. ${label} '{"name":"Example"}'`,
9
+ });
10
+ }
11
+ }
12
+
13
+ export function mergeData(flags, rawData) {
14
+ return rawData ? { ...flags, ...parseJsonOption(rawData, '--data') } : flags;
15
+ }
16
+
17
+ export function money(amount, currency) {
18
+ return amount !== undefined ? { amount: String(amount), currency } : undefined;
19
+ }
20
+
21
+ export function csv(value) {
22
+ return String(value).split(',').map((v) => v.trim()).filter(Boolean);
23
+ }
24
+
25
+ export function collect(value, previous) {
26
+ return previous.concat([value]);
27
+ }
@@ -0,0 +1,102 @@
1
+ import { Command } from 'commander';
2
+ import { api, apiAll } from '../api/client.js';
3
+ import { AsaError } from '../errors.js';
4
+ import { print } from '../output.js';
5
+ import { mergeData, money, parseJsonOption } from './helpers.js';
6
+
7
+ const MATCH_TYPES = ['BROAD', 'EXACT'];
8
+
9
+ const base = (campaignId, adgroupId) => `/campaigns/${campaignId}/adgroups/${adgroupId}/targetingkeywords`;
10
+
11
+ export function registerKeywords(program) {
12
+ const cmd = new Command('keywords').description('Manage targeting keywords within ad groups');
13
+
14
+ cmd.command('list').description('List keywords in an ad group')
15
+ .requiredOption('--campaign <id>', 'campaign ID')
16
+ .requiredOption('--adgroup <id>', 'ad group ID')
17
+ .option('--limit <n>', 'max results (1-1000)', '20')
18
+ .option('--offset <n>', 'result offset', '0')
19
+ .option('--all', 'fetch every page')
20
+ .action(async (opts) => {
21
+ if (opts.all) return print({ data: await apiAll(base(opts.campaign, opts.adgroup)), error: null });
22
+ print(await api('GET', base(opts.campaign, opts.adgroup), { query: { limit: opts.limit, offset: opts.offset } }));
23
+ });
24
+
25
+ cmd.command('get').description('Get a single keyword')
26
+ .argument('<id>', 'keyword ID')
27
+ .requiredOption('--campaign <id>', 'campaign ID')
28
+ .requiredOption('--adgroup <id>', 'ad group ID')
29
+ .action(async (id, opts) => print(await api('GET', `${base(opts.campaign, opts.adgroup)}/${id}`)));
30
+
31
+ cmd.command('create').description('Create keywords (always a bulk call; use --data for several)')
32
+ .requiredOption('--campaign <id>', 'campaign ID')
33
+ .requiredOption('--adgroup <id>', 'ad group ID')
34
+ .option('--text <text>', 'keyword text')
35
+ .option('--match-type <type>', 'BROAD or EXACT', 'BROAD')
36
+ .option('--bid <amount>', 'bid amount')
37
+ .option('--currency <code>', 'currency (required with --bid)')
38
+ .option('--status <status>', 'ENABLED or PAUSED', 'ENABLED')
39
+ .option('--data <json>', 'raw JSON array for bulk create, e.g. \'[{"text":"shoes","matchType":"EXACT"}]\'')
40
+ .action(async (opts) => {
41
+ let body;
42
+ if (opts.data) {
43
+ body = parseJsonOption(opts.data, '--data');
44
+ if (!Array.isArray(body)) body = [body];
45
+ } else {
46
+ if (!opts.text) {
47
+ throw new AsaError('--text is required (or pass a bulk array with --data)', {
48
+ hint: 'Example: asa keywords create --campaign 123 --adgroup 456 --text "running shoes" --match-type EXACT',
49
+ });
50
+ }
51
+ const matchType = opts.matchType.toUpperCase();
52
+ if (!MATCH_TYPES.includes(matchType)) {
53
+ throw new AsaError(`Invalid match type "${opts.matchType}"`, {
54
+ hint: `Valid match types: ${MATCH_TYPES.join(', ')}`,
55
+ });
56
+ }
57
+ body = [mergeData({
58
+ text: opts.text,
59
+ matchType,
60
+ bidAmount: money(opts.bid, opts.currency),
61
+ status: opts.status,
62
+ }, undefined)];
63
+ }
64
+ print(await api('POST', `${base(opts.campaign, opts.adgroup)}/bulk`, { body }));
65
+ });
66
+
67
+ cmd.command('update').description('Update keywords (bulk; repeat --id via --data for several)')
68
+ .argument('<id>', 'keyword ID')
69
+ .requiredOption('--campaign <id>', 'campaign ID')
70
+ .requiredOption('--adgroup <id>', 'ad group ID')
71
+ .option('--bid <amount>')
72
+ .option('--currency <code>', 'currency (required with --bid)')
73
+ .option('--status <status>', 'ENABLED or PAUSED')
74
+ .option('--data <json>', 'raw JSON array of keyword updates, replacing the flag-built body')
75
+ .action(async (id, opts) => {
76
+ let body;
77
+ if (opts.data) {
78
+ body = parseJsonOption(opts.data, '--data');
79
+ if (!Array.isArray(body)) body = [body];
80
+ } else {
81
+ const single = mergeData({
82
+ id: Number(id),
83
+ bidAmount: money(opts.bid, opts.currency),
84
+ status: opts.status,
85
+ }, undefined);
86
+ for (const key of Object.keys(single)) if (single[key] === undefined) delete single[key];
87
+ body = [single];
88
+ }
89
+ print(await api('PUT', `${base(opts.campaign, opts.adgroup)}/bulk`, { body }));
90
+ });
91
+
92
+ cmd.command('delete').description('Delete a keyword')
93
+ .argument('<id>', 'keyword ID')
94
+ .requiredOption('--campaign <id>', 'campaign ID')
95
+ .requiredOption('--adgroup <id>', 'ad group ID')
96
+ .action(async (id, opts) => {
97
+ await api('DELETE', `${base(opts.campaign, opts.adgroup)}/${id}`);
98
+ print({ ok: true, deleted: Number(id) });
99
+ });
100
+
101
+ program.addCommand(cmd);
102
+ }
@@ -0,0 +1,69 @@
1
+ import { apiAll } from '../api/client.js';
2
+ import { getOrgId, readJson } from '../config.js';
3
+ import { AsaError } from '../errors.js';
4
+ import { addNote, clearMemory, getNotes, readActivity } from '../memory.js';
5
+ import { print } from '../output.js';
6
+
7
+ const campaignSummary = (c) => ({
8
+ id: c.id,
9
+ name: c.name,
10
+ status: c.status,
11
+ servingStatus: c.servingStatus,
12
+ dailyBudget: c.dailyBudgetAmount,
13
+ countriesOrRegions: c.countriesOrRegions,
14
+ });
15
+
16
+ async function context(opts) {
17
+ const orgId = getOrgId();
18
+ if (!orgId) throw new AsaError('Not logged in', { hint: 'Run "asa login" first.' });
19
+ const out = {
20
+ org: orgId,
21
+ notes: getNotes(orgId),
22
+ recentActivity: readActivity(orgId, Number(opts.limit)),
23
+ };
24
+ if (opts.live) {
25
+ const campaigns = await apiAll('/campaigns');
26
+ out.campaigns = campaigns.filter((c) => !c.deleted).map(campaignSummary);
27
+ }
28
+ print(out);
29
+ }
30
+
31
+ function remember(note, opts) {
32
+ const refs = {};
33
+ if (opts.campaign) refs.campaignId = Number(opts.campaign);
34
+ if (opts.adgroup) refs.adGroupId = Number(opts.adgroup);
35
+ if (opts.keyword) refs.keywordId = Number(opts.keyword);
36
+ const total = addNote(note, refs);
37
+ print({ ok: true, notes: total });
38
+ }
39
+
40
+ function memory(opts) {
41
+ const orgId = getOrgId();
42
+ if (opts.clear) {
43
+ clearMemory(orgId);
44
+ return print({ ok: true, cleared: orgId });
45
+ }
46
+ print({ org: orgId, notes: getNotes(orgId), recentActivity: readActivity(orgId, Number(opts.limit)) });
47
+ }
48
+
49
+ export function registerMemory(program) {
50
+ program.command('context')
51
+ .description('Session-start snapshot for LLM agents: notes, recent mutations, and (with --live) current campaigns')
52
+ .option('--live', 'include the current campaign list from the API')
53
+ .option('--limit <n>', 'activity entries to include', '20')
54
+ .action(context);
55
+
56
+ program.command('remember')
57
+ .description('Store a note in memory so future sessions keep the context')
58
+ .argument('<note>', 'free-text note, e.g. "Lowered US bids to 1/3 because of IR/RU traffic"')
59
+ .option('--campaign <id>', 'attach the note to a campaign')
60
+ .option('--adgroup <id>', 'attach the note to an ad group')
61
+ .option('--keyword <id>', 'attach the note to a keyword')
62
+ .action(remember);
63
+
64
+ program.command('memory')
65
+ .description('Dump (or with --clear, erase) the stored memory for the active org')
66
+ .option('--limit <n>', 'activity entries to include', '50')
67
+ .option('--clear', 'erase all memory for the active org')
68
+ .action(memory);
69
+ }