adapty 0.9.2 → 0.9.4

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/README.md CHANGED
@@ -185,6 +185,16 @@ requiring confirmation exit **6** without it; there is no interactive prompt. Th
185
185
  status before refusing, but does not send the action request. After an action, check status again.
186
186
  After `revision_conflict`, read status and review the current action, input and confirmation before retrying.
187
187
 
188
+ For an `agent` action (`migrate_code`), run it from the app's git repository, ideally from your coding agent. It
189
+ writes the migration's data to `.git/adapty/<migration ID>/` and prints what the agent needs: a branch to work on (`adapty-migrate`), which skill to use, where the data
190
+ is, and the server's guide. Outside an agent, paste the output into one. The agent reports back by running the
191
+ action again with `--input`:
192
+
193
+ ```sh
194
+ adapty migrations run migrate_code
195
+ adapty migrations run migrate_code --input '{"summary": "Swapped the SDK; ADAPTY_SETUP.md leaves 3 steps"}'
196
+ ```
197
+
188
198
  For an `external` action, complete the step in the browser, then check status:
189
199
 
190
200
  ```sh
@@ -249,6 +259,22 @@ adapty migrations steps -m mig_7x2 --json | jq '.steps'
249
259
  adapty migrations show RESOURCE -m mig_7x2 --json | jq '.result'
250
260
  ```
251
261
 
262
+ ### Skills
263
+
264
+ Install the Adapty skills into the coding agents on this machine. Claude Code, Codex and Gemini CLI found on
265
+ `PATH` get them through their own plugin or skills command:
266
+
267
+ ```sh
268
+ adapty skills install
269
+ adapty skills install --agent claude-code
270
+ adapty skills install --yes
271
+ ```
272
+
273
+ When several agents are found, the command asks which to install into; with no one to ask (a pipe or
274
+ `--json`), pass `--agent` or `--yes` for all of them, or it exits **2**. When none is found, it runs
275
+ `npx skills add adaptyteam/adapty-skills --all --global`, which covers the other agents. Restart an agent to
276
+ load the skills. Exit **1** means an install failed.
277
+
252
278
  ### Apple Search Ads
253
279
 
254
280
  Apple Search Ads commands live under `adapty asa` and talk to the ASA service rather than the Developer
@@ -339,8 +365,14 @@ adapty asa negative-keywords add --ad-group UUID --text free
339
365
  adapty asa negative-keywords add --campaign UUID [--all-ad-groups] --text free
340
366
 
341
367
  adapty asa search-terms list [--date-from ... --date-to ...]
368
+
369
+ adapty asa change-history list --campaign-group UUID [--date-from ... --date-to ...] [--entity-type Campaign --event-type UPDATE]
370
+ adapty asa change-history list --campaign-group UUID --fields # flat rows: field, old values, new values (page size 10)
371
+ adapty asa change-history get Campaign.444555666.TXN_ID --campaign-group UUID # detail_id comes from the list
342
372
  ```
343
373
 
374
+ Change history is read live from Apple (proxied from the Apple Ads Platform API; Apple keeps 6 months). Its id filters (`--campaign`, `--ad-group`, `--entity-id`, `--user`, `--txn`) take Apple's own ids, not UUIDs; `modified_by` is an Apple user id, not an email.
375
+
344
376
  Product pages and rule-based automations:
345
377
 
346
378
  ```sh
@@ -1,4 +1,5 @@
1
1
  import { MigrationCommand } from '../../../base/adapty/index.js';
2
+ import type { Handoff } from './lib/agent.js';
2
3
  import type { Envelope } from '../../../../sdk/adapty/index.js';
3
4
  export default class Run extends MigrationCommand {
4
5
  static summary: string;
@@ -18,8 +19,10 @@ export default class Run extends MigrationCommand {
18
19
  'no-browser': import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
20
  migration: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
21
  };
21
- run(): Promise<Envelope>;
22
+ run(): Promise<Envelope | Handoff>;
22
23
  private prepare;
24
+ /** The agent that ran this command, or the one the developer pastes the output into, does the work. */
25
+ private handOff;
23
26
  private runExternalAction;
24
27
  private runInputAction;
25
28
  }
@@ -1,3 +1,5 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
1
3
  import { Args, Flags } from '@oclif/core';
2
4
  import { MigrationCommand } from '../../../base/adapty/index.js';
3
5
  import { CliError, exitCode } from '../../../errors.js';
@@ -5,10 +7,11 @@ import { migrationFlags } from '../../../input/migration.js';
5
7
  import { renderEnvelope } from '../../../views/migrations/envelope/envelope.js';
6
8
  import { actionView } from './lib/action-view.js';
7
9
  import { findAction, unknownActionMessage, unsupportedActionMessage } from './lib/actions.js';
10
+ import { buildHandoff, PATH_SEGMENT, repository } from './lib/agent.js';
8
11
  import { readActionInput } from './lib/input.js';
9
12
  import { openLink } from './lib/open-link.js';
10
13
  export default class Run extends MigrationCommand {
11
- static summary = 'Run an input action or open an external action link';
14
+ static summary = 'Run an input action, open an external action link, or hand an agent action to a coding agent';
12
15
  static description = [
13
16
  'Choose an action ID from next_actions or available_actions in `adapty migrations status -m ID --json`.',
14
17
  'Replace ACTION_ID in the examples with an offered action ID.',
@@ -21,7 +24,12 @@ export default class Run extends MigrationCommand {
21
24
  'Pipes and --json require --open to launch a browser; BROWSER=none disables it.',
22
25
  'File uploads are not supported; use the dashboard or an offered Cloud Export action.',
23
26
  '',
24
- 'With --json, returns the full migration response (before the browser step for external actions).',
27
+ 'An agent action writes the resources in reads to .git/adapty/<migration ID>/ in the app\'s git repository and',
28
+ 'prints what a coding agent needs to do it. Run it from the agent, or paste its output into one. The agent',
29
+ 'reports the result by running the action again with --input.',
30
+ '',
31
+ 'With --json, returns the full migration response (before the browser step for external actions). For an agent',
32
+ 'action without --input, returns { action_id, files, instructions }: the files written and the text for the agent.',
25
33
  'After a revision_conflict error, read status and review the action before retrying.',
26
34
  ].join('\n');
27
35
  static examples = [
@@ -89,6 +97,9 @@ export default class Run extends MigrationCommand {
89
97
  if (context.href !== undefined) {
90
98
  return this.runExternalAction(context, context.href);
91
99
  }
100
+ if (context.action.kind === 'agent' && context.input === undefined) {
101
+ return this.handOff(context);
102
+ }
92
103
  return this.runInputAction(context);
93
104
  }
94
105
  async prepare() {
@@ -108,20 +119,53 @@ export default class Run extends MigrationCommand {
108
119
  }
109
120
  return {
110
121
  action, envelope, flags, href, selection,
111
- input: fromStdin && action.kind === 'input' ? await readActionInput(flags) : input,
122
+ input: fromStdin && (action.kind === 'input' || action.kind === 'agent') ? await readActionInput(flags) : input,
112
123
  };
113
124
  }
125
+ /** The agent that ran this command, or the one the developer pastes the output into, does the work. */
126
+ async handOff({ action, envelope }) {
127
+ const repo = await repository(process.cwd());
128
+ if (repo === undefined) {
129
+ throw new CliError(`\`${action.action_id}\` works on the app code: run it from the app's git repository.`, exitCode.usage, 'not_a_repository');
130
+ }
131
+ const migrationId = envelope.migration.id;
132
+ if (!PATH_SEGMENT.test(migrationId)) {
133
+ throw new CliError(`The server named a migration \`${migrationId}\` that cannot be a folder name.`, 1, 'migration_id_invalid');
134
+ }
135
+ // A folder per migration: two migrations handed off in one repository never read each other's data.
136
+ const dir = join(repo.gitDir, 'adapty', migrationId);
137
+ const files = [];
138
+ const guides = [];
139
+ await mkdir(dir, { recursive: true });
140
+ for (const name of action.reads) {
141
+ const { result } = await this.adapty.migrations.resource(migrationId, name);
142
+ if (typeof result === 'object' && result !== null && 'markdown' in result && typeof result.markdown === 'string') {
143
+ guides.push(result.markdown);
144
+ }
145
+ else if (PATH_SEGMENT.test(name)) {
146
+ const file = join(dir, `${name}.json`);
147
+ await writeFile(file, `${JSON.stringify(result, null, 2)}\n`);
148
+ files.push(`\`${name}\`: ${file}`);
149
+ }
150
+ else {
151
+ throw new CliError(`The server named a resource \`${name}\` that cannot be a file name.`, 1, 'resource_name_invalid');
152
+ }
153
+ }
154
+ const handoff = buildHandoff({ actionId: action.action_id, files, guides });
155
+ this.render(handoff, ({ instructions }) => instructions);
156
+ return handoff;
157
+ }
114
158
  async runExternalAction(context, href) {
115
159
  this.render({ action: context.action, migrationId: context.envelope.migration.id }, actionView);
116
160
  await openLink(href, context.flags, this.interactive);
117
161
  return context.envelope;
118
162
  }
119
163
  async runInputAction({ action, envelope, flags, input, selection }) {
120
- if (action.kind !== 'input' && action.kind !== 'upload' && action.kind !== 'external') {
164
+ if (action.kind !== 'input' && action.kind !== 'upload' && action.kind !== 'external' && action.kind !== 'agent') {
121
165
  this.render({ action, migrationId: envelope.migration.id }, actionView);
122
166
  return envelope;
123
167
  }
124
- if (action.kind !== 'input') {
168
+ if (action.kind !== 'input' && action.kind !== 'agent') {
125
169
  throw new CliError(unsupportedActionMessage(action), exitCode.usage, 'action_unsupported');
126
170
  }
127
171
  if (action.confirm !== null && !flags.yes) {
@@ -0,0 +1,24 @@
1
+ /** In a worktree or a submodule `.git` is a file, so the git directory comes from git itself. */
2
+ export declare const repository: (cwd: string) => Promise<{
3
+ gitDir: string;
4
+ } | undefined>;
5
+ export type HandoffInput = {
6
+ actionId: string;
7
+ /** `name`: absolute path, one per resource written to a file. */
8
+ files: string[];
9
+ /** Markdown resources, the server's instructions for this migration, handed to the agent as they are. */
10
+ guides: string[];
11
+ };
12
+ /** What `--json` returns for an agent action, and what the human view prints. */
13
+ export type Handoff = {
14
+ action_id: string;
15
+ files: string[];
16
+ instructions: string;
17
+ };
18
+ /** A resource name or a migration ID becomes a path segment: no separators, no dots to climb out with. */
19
+ export declare const PATH_SEGMENT: RegExp;
20
+ /**
21
+ * The server says what to do; the CLI adds the one thing only it knows, where it put the data. Paths
22
+ * are absolute, so they open from any folder the agent runs in, a monorepo's app folder included.
23
+ */
24
+ export declare const buildHandoff: ({ actionId, files, guides }: HandoffInput) => Handoff;
@@ -0,0 +1,32 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { resolve } from 'node:path';
3
+ import { promisify } from 'node:util';
4
+ const git = promisify(execFile);
5
+ /** In a worktree or a submodule `.git` is a file, so the git directory comes from git itself. */
6
+ export const repository = async (cwd) => {
7
+ try {
8
+ const { stdout } = await git('git', ['rev-parse', '--absolute-git-dir'], { cwd });
9
+ const gitDir = stdout.trim();
10
+ // git prints forward slashes on Windows too: resolve() gives the platform's own form.
11
+ return gitDir === '' ? undefined : { gitDir: resolve(gitDir) };
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ };
17
+ /** A resource name or a migration ID becomes a path segment: no separators, no dots to climb out with. */
18
+ export const PATH_SEGMENT = /^[a-z0-9][a-z0-9-]*$/;
19
+ /**
20
+ * The server says what to do; the CLI adds the one thing only it knows, where it put the data. Paths
21
+ * are absolute, so they open from any folder the agent runs in, a monorepo's app folder included.
22
+ */
23
+ export const buildHandoff = ({ actionId, files, guides }) => ({
24
+ action_id: actionId,
25
+ files,
26
+ instructions: [
27
+ 'adapty-cli wrote this migration\'s data to:',
28
+ ...files.map(file => `- ${file}`),
29
+ '',
30
+ ...guides.map(guide => guide.trim()),
31
+ ].join('\n'),
32
+ });
@@ -0,0 +1,14 @@
1
+ import { BaseCommand } from '../../../base/base-command.js';
2
+ import type { InstallResult } from './lib/agents.js';
3
+ export default class SkillsInstall extends BaseCommand {
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: {
7
+ agent: import("@oclif/core/interfaces").OptionFlag<("claude-code" | "codex" | "gemini-cli")[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ };
10
+ run(): Promise<InstallResult[]>;
11
+ private targets;
12
+ /** The skills CLI runs code from npm, so it needs a yes from someone at a terminal, whatever the flags say. */
13
+ private fallbackTarget;
14
+ }
@@ -0,0 +1,78 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base/base-command.js';
3
+ import { CliError, exitCode } from '../../../errors.js';
4
+ import { agentIds, agents, fallback, FALLBACK_COMMAND, install, onPath, runQuietly } from './lib/agents.js';
5
+ import { choose, confirm } from './lib/choose.js';
6
+ import { renderInstall } from './lib/render.js';
7
+ export default class SkillsInstall extends BaseCommand {
8
+ static description = 'Install the Adapty skills into the coding agents on this machine';
9
+ static examples = [
10
+ '<%= config.bin %> skills install',
11
+ '<%= config.bin %> skills install --agent claude-code',
12
+ '<%= config.bin %> skills install --yes',
13
+ ];
14
+ static flags = {
15
+ agent: Flags.option({
16
+ description: 'Install into this agent only. Repeat for several.',
17
+ multiple: true,
18
+ options: agentIds,
19
+ })(),
20
+ yes: Flags.boolean({
21
+ char: 'y',
22
+ description: 'Install into every agent found without asking; required when several are found and no one can answer. '
23
+ + 'Never runs the npx fallback, which is always asked for',
24
+ }),
25
+ };
26
+ async run() {
27
+ const { flags } = await this.parse(SkillsInstall);
28
+ const named = flags.agent !== undefined;
29
+ const chosen = named ? agents.filter(agent => flags.agent?.includes(agent.id)) : agents;
30
+ const results = [];
31
+ const found = [];
32
+ for (const agent of chosen) {
33
+ if (await onPath(agent.bin)) {
34
+ found.push(agent);
35
+ }
36
+ else if (named) {
37
+ results.push({ agent: agent.id, error: `\`${agent.bin}\` is not on PATH`, installed: false });
38
+ }
39
+ }
40
+ for (const agent of await this.targets({ found, named, yes: flags.yes })) {
41
+ results.push(await install(agent, runQuietly));
42
+ }
43
+ this.render(results, renderInstall);
44
+ if (results.some(result => !result.installed)) {
45
+ process.exitCode = 1;
46
+ }
47
+ return results;
48
+ }
49
+ async targets({ found, named, yes }) {
50
+ if (found.length === 0 && !named) {
51
+ return this.fallbackTarget();
52
+ }
53
+ if (named || yes || found.length === 1) {
54
+ return found;
55
+ }
56
+ if (!this.interactive || !process.stdin.isTTY) {
57
+ const names = found.map(agent => agent.name).join(', ');
58
+ throw new CliError(`Found ${names}. Choose with --agent, or pass --yes to install into all of them.`, exitCode.usage, 'agent_choice_required');
59
+ }
60
+ return choose(found, this.signal);
61
+ }
62
+ /** The skills CLI runs code from npm, so it needs a yes from someone at a terminal, whatever the flags say. */
63
+ async fallbackTarget() {
64
+ const bins = agents.map(agent => `\`${agent.bin}\``).join(', ');
65
+ if (!(await onPath(fallback.bin))) {
66
+ throw new CliError(`Found none of ${bins}, and no \`npx\` to run the skills CLI with.`, 1, 'no_agent_found');
67
+ }
68
+ if (!this.interactive || !process.stdin.isTTY) {
69
+ throw new CliError(`Found none of ${bins}. To install with the skills CLI, run \`${FALLBACK_COMMAND}\` yourself.`, exitCode.usage, 'fallback_confirmation_required');
70
+ }
71
+ const question = `Found none of ${bins}. Run \`${FALLBACK_COMMAND}\`? It downloads the skills CLI from npm `
72
+ + 'and installs the Adapty skills into every agent it finds.';
73
+ if (!(await confirm(question, this.signal))) {
74
+ throw new CliError('Nothing installed.', 1, 'fallback_declined');
75
+ }
76
+ return [fallback];
77
+ }
78
+ }
@@ -0,0 +1 @@
1
+ export { default } from './command.js';
@@ -0,0 +1 @@
1
+ export { default } from './command.js';
@@ -0,0 +1,33 @@
1
+ export declare const agentIds: readonly ["claude-code", "codex", "gemini-cli"];
2
+ export type AgentId = (typeof agentIds)[number];
3
+ export type Agent<Id extends string = AgentId | 'other'> = {
4
+ bin: string;
5
+ id: Id;
6
+ name: string;
7
+ steps: string[][];
8
+ };
9
+ /**
10
+ * Each agent's own installer, the same commands the docs' "Install agent tools" modal shows. A
11
+ * marketplace step fails harmlessly when the marketplace is already there, so only the last step
12
+ * decides the outcome.
13
+ */
14
+ export declare const agents: readonly Agent<AgentId>[];
15
+ /**
16
+ * For a machine where none of the agents above is found: the skills CLI detects the rest itself and
17
+ * installs into every agent it finds. It runs code from npm, so it only ever runs after a yes.
18
+ */
19
+ export declare const fallback: Agent;
20
+ export declare const FALLBACK_COMMAND = "npx skills@1.7.0 add adaptyteam/adapty-skills --all --global";
21
+ export type Run = (bin: string, args: readonly string[]) => Promise<{
22
+ code: number | null;
23
+ stderr: string;
24
+ }>;
25
+ export type InstallResult = {
26
+ agent: Agent['id'];
27
+ error?: string;
28
+ installed: boolean;
29
+ warning?: string;
30
+ };
31
+ export declare const runQuietly: Run;
32
+ export declare const onPath: (bin: string) => Promise<boolean>;
33
+ export declare const install: (agent: Agent, run: Run) => Promise<InstallResult>;
@@ -0,0 +1,91 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { access, constants } from 'node:fs/promises';
3
+ import { delimiter, join } from 'node:path';
4
+ const SOURCE = 'adaptyteam/adapty-skills';
5
+ const STDERR_TAIL_LINES = 5;
6
+ export const agentIds = ['claude-code', 'codex', 'gemini-cli'];
7
+ /**
8
+ * Each agent's own installer, the same commands the docs' "Install agent tools" modal shows. A
9
+ * marketplace step fails harmlessly when the marketplace is already there, so only the last step
10
+ * decides the outcome.
11
+ */
12
+ export const agents = [
13
+ {
14
+ bin: 'claude',
15
+ id: 'claude-code',
16
+ name: 'Claude Code',
17
+ steps: [['plugin', 'marketplace', 'add', SOURCE], ['plugin', 'install', 'adapty-skills@adapty']],
18
+ },
19
+ {
20
+ bin: 'codex',
21
+ id: 'codex',
22
+ name: 'Codex',
23
+ steps: [['plugin', 'marketplace', 'add', SOURCE], ['plugin', 'add', 'adapty-skills@adapty']],
24
+ },
25
+ { bin: 'gemini', id: 'gemini-cli', name: 'Gemini CLI', steps: [['skills', 'install', `https://github.com/${SOURCE}`]] },
26
+ ];
27
+ /** Pinned: the fallback runs this package from npm, so a new release is taken on purpose, not by accident. */
28
+ const SKILLS_CLI = 'skills@1.7.0';
29
+ /**
30
+ * For a machine where none of the agents above is found: the skills CLI detects the rest itself and
31
+ * installs into every agent it finds. It runs code from npm, so it only ever runs after a yes.
32
+ */
33
+ export const fallback = {
34
+ bin: 'npx',
35
+ id: 'other',
36
+ name: 'Other agents (skills CLI)',
37
+ steps: [['--yes', SKILLS_CLI, 'add', SOURCE, '--all', '--global']],
38
+ };
39
+ export const FALLBACK_COMMAND = `npx ${SKILLS_CLI} add ${SOURCE} --all --global`;
40
+ // A Windows agent is a .cmd shim that only a shell resolves.
41
+ const windows = process.platform === 'win32';
42
+ export const runQuietly = async (bin, args) => new Promise((resolve) => {
43
+ const child = spawn(bin, args, { env: { ...process.env, NO_COLOR: '1' }, shell: windows, stdio: ['ignore', 'ignore', 'pipe'] });
44
+ let stderr = '';
45
+ child.stderr.on('data', (chunk) => {
46
+ stderr += chunk.toString();
47
+ });
48
+ child.on('error', (error) => {
49
+ resolve({ code: null, stderr: error.message });
50
+ });
51
+ child.on('close', (code) => {
52
+ resolve({ code, stderr });
53
+ });
54
+ });
55
+ export const onPath = async (bin) => {
56
+ const extensions = windows ? (process.env.PATHEXT ?? '.EXE;.CMD').split(';') : [''];
57
+ const candidates = (process.env.PATH ?? '').split(delimiter).filter(Boolean)
58
+ .flatMap(dir => extensions.map(extension => join(dir, bin + extension)));
59
+ for (const candidate of candidates) {
60
+ try {
61
+ await access(candidate, constants.X_OK);
62
+ return true;
63
+ }
64
+ catch {
65
+ // not in this directory
66
+ }
67
+ }
68
+ return false;
69
+ };
70
+ const tailOf = (stderr) => stderr.trim().split('\n').slice(-STDERR_TAIL_LINES).join('\n');
71
+ export const install = async (agent, run) => {
72
+ const results = [];
73
+ for (const step of agent.steps) {
74
+ results.push(await run(agent.bin, step));
75
+ }
76
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- every agent has at least one step
77
+ const last = results.at(-1);
78
+ if (last.code !== 0) {
79
+ const error = tailOf(last.stderr) || `\`${agent.bin}\` exited with code ${String(last.code)}`;
80
+ return { agent: agent.id, error, installed: false };
81
+ }
82
+ // A marketplace that is already there fails the step, whether it is ours or another source under
83
+ // the same name; only the second installs someone else's skills, and the CLI cannot tell them apart.
84
+ const marketplace = results.length > 1 ? results[0] : undefined;
85
+ if (marketplace !== undefined && marketplace.code !== 0) {
86
+ const warning = `adding the marketplace failed (${tailOf(marketplace.stderr) || 'no message'}). If an \`adapty\` `
87
+ + `marketplace already existed, the skills came from its source: check it with \`${agent.bin} plugin marketplace list\`.`;
88
+ return { agent: agent.id, installed: true, warning };
89
+ }
90
+ return { agent: agent.id, installed: true };
91
+ };
@@ -0,0 +1,7 @@
1
+ import type { Agent } from './agents.js';
2
+ /** `1,3` or `1 3` picks those; an empty answer picks all of them. Anything else is asked again. */
3
+ export declare const parseChoice: (answer: string, count: number) => number[] | undefined;
4
+ export declare const choose: (found: readonly Agent[], signal: AbortSignal) => Promise<Agent[]>;
5
+ /** An empty answer is a no: the question guards a command that runs code from npm. */
6
+ export declare const parseConsent: (answer: string) => boolean | undefined;
7
+ export declare const confirm: (question: string, signal: AbortSignal) => Promise<boolean>;
@@ -0,0 +1,62 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { CliError, exitCode } from '../../../../errors.js';
3
+ /** `1,3` or `1 3` picks those; an empty answer picks all of them. Anything else is asked again. */
4
+ export const parseChoice = (answer, count) => {
5
+ const trimmed = answer.trim();
6
+ if (trimmed === '') {
7
+ return Array.from({ length: count }, (_, index) => index);
8
+ }
9
+ const picked = trimmed.split(/[\s,]+/).map(Number);
10
+ if (picked.some(number => !Number.isInteger(number) || number < 1 || number > count)) {
11
+ return undefined;
12
+ }
13
+ return [...new Set(picked)].sort((a, b) => a - b).map(number => number - 1);
14
+ };
15
+ /**
16
+ * Asked on stderr, so the answer never mixes into what a program reads from stdout. A terminal's
17
+ * Ctrl+C reaches readline, not the process, so the prompt turns it into the usual exit 130 itself.
18
+ */
19
+ const ask = async ({ question, retry, signal }, parse) => {
20
+ const reader = createInterface({ input: process.stdin, output: process.stderr });
21
+ const interrupted = new AbortController();
22
+ reader.on('SIGINT', () => {
23
+ interrupted.abort();
24
+ });
25
+ try {
26
+ for (;;) {
27
+ const answer = await reader.question(question, { signal: AbortSignal.any([signal, interrupted.signal]) });
28
+ const parsed = parse(answer);
29
+ if (parsed !== undefined) {
30
+ return parsed;
31
+ }
32
+ process.stderr.write(`${retry}\n`);
33
+ }
34
+ }
35
+ catch (error) {
36
+ if (signal.aborted || interrupted.signal.aborted) {
37
+ throw new CliError('Cancelled.', exitCode.cancelled, undefined, { cause: error });
38
+ }
39
+ throw error;
40
+ }
41
+ finally {
42
+ reader.close();
43
+ }
44
+ };
45
+ export const choose = async (found, signal) => {
46
+ const list = found.map((agent, index) => ` ${String(index + 1)}. ${agent.name}`).join('\n');
47
+ const picked = await ask({
48
+ question: `Found:\n${list}\nInstall into which? [all] `,
49
+ retry: `Type numbers from 1 to ${String(found.length)}, or press Enter for all.`,
50
+ signal,
51
+ }, answer => parseChoice(answer, found.length));
52
+ return picked.map(index => found[index]).filter(agent => agent !== undefined);
53
+ };
54
+ /** An empty answer is a no: the question guards a command that runs code from npm. */
55
+ export const parseConsent = (answer) => {
56
+ const trimmed = answer.trim().toLowerCase();
57
+ if (trimmed === '' || trimmed === 'n' || trimmed === 'no') {
58
+ return false;
59
+ }
60
+ return trimmed === 'y' || trimmed === 'yes' ? true : undefined;
61
+ };
62
+ export const confirm = async (question, signal) => ask({ question: `${question} [y/N] `, retry: 'Type y or n.', signal }, parseConsent);
@@ -0,0 +1,2 @@
1
+ import type { InstallResult } from './agents.js';
2
+ export declare const renderInstall: (results: InstallResult[]) => string;
@@ -0,0 +1,16 @@
1
+ import { agents, fallback, FALLBACK_COMMAND } from './agents.js';
2
+ const known = [...agents, fallback];
3
+ const nameOf = (result) => known.find(agent => agent.id === result.agent)?.name ?? result.agent;
4
+ export const renderInstall = (results) => {
5
+ const lines = results.map((result) => {
6
+ if (!result.installed) {
7
+ return `✗ ${nameOf(result)}: ${result.error ?? 'not installed'}`;
8
+ }
9
+ const installed = `✓ ${nameOf(result)}: installed. Restart it to load the skills.`;
10
+ return result.warning === undefined ? installed : `${installed}\n Warning: ${result.warning}`;
11
+ });
12
+ if (results.some(result => result.agent === fallback.id)) {
13
+ return lines.join('\n');
14
+ }
15
+ return [...lines, '', `Another agent? Run \`${FALLBACK_COMMAND}\`.`].join('\n');
16
+ };
@@ -1,4 +1,4 @@
1
- const knownKinds = new Set(['external', 'input', 'upload']);
1
+ const knownKinds = new Set(['agent', 'external', 'input', 'upload']);
2
2
  const indent = (text, pad) => {
3
3
  return text.split('\n').map(line => `${pad}${line}`).join('\n');
4
4
  };
@@ -0,0 +1,14 @@
1
+ import { Command } from '@oclif/core';
2
+ import type { AsaChangeHistoryDetailDTO } from '../../../lib/asa-schemas.js';
3
+ export default class AsaChangeHistoryGet extends Command {
4
+ static args: {
5
+ detail_id: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static enableJsonFlag: boolean;
9
+ static examples: string[];
10
+ static flags: {
11
+ 'campaign-group': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ };
13
+ run(): Promise<AsaChangeHistoryDetailDTO>;
14
+ }
@@ -0,0 +1,39 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { createAsaClient } from '../../../lib/asa-client.js';
3
+ import { isValidUuid } from '../../../lib/flags.js';
4
+ import { printResponse } from '../../../lib/output.js';
5
+ const DETAIL_ID_REGEX = /^[A-Za-z]+\.[\w-]+\.[\w-]+$/;
6
+ export default class AsaChangeHistoryGet extends Command {
7
+ static args = {
8
+ detail_id: Args.string({
9
+ description: 'Detail ID from asa change-history list (EntityType.entityId.txnId)',
10
+ required: true,
11
+ }),
12
+ };
13
+ static description = 'Field-level before/after of one change (proxied from the Apple Ads Platform API)';
14
+ static enableJsonFlag = true;
15
+ static examples = ['<%= config.bin %> asa change-history get Campaign.444555666.TXN_ID --campaign-group <uuid>'];
16
+ static flags = {
17
+ 'campaign-group': Flags.string({
18
+ description: 'Organization the change belongs to (UUID; take campaign_group_id from the list row)',
19
+ required: true,
20
+ }),
21
+ };
22
+ async run() {
23
+ const { args, flags } = await this.parse(AsaChangeHistoryGet);
24
+ if (!DETAIL_ID_REGEX.test(args.detail_id)) {
25
+ this.error('Invalid detail ID format. Take it from `adapty asa change-history list` (EntityType.entityId.txnId).', {
26
+ exit: 2,
27
+ });
28
+ }
29
+ if (!isValidUuid(flags['campaign-group'])) {
30
+ this.error('Invalid campaign group ID format. Run `adapty asa orgs list` to find it.', { exit: 2 });
31
+ }
32
+ const client = await createAsaClient(this);
33
+ const result = await client.get(`/change-history/${args.detail_id}`, {
34
+ campaign_group_id: flags['campaign-group'],
35
+ });
36
+ printResponse(result, this.log.bind(this));
37
+ return result;
38
+ }
39
+ }
@@ -0,0 +1,27 @@
1
+ import { Command } from '@oclif/core';
2
+ import type { AsaChangeHistoryEventDTO, AsaChangeHistoryFieldEventDTO } from '../../../lib/asa-schemas.js';
3
+ import type { PaginatedResponse } from '../../../lib/flags.js';
4
+ type ChangeHistoryPage = PaginatedResponse<AsaChangeHistoryEventDTO> | PaginatedResponse<AsaChangeHistoryFieldEventDTO>;
5
+ export default class AsaChangeHistoryList extends Command {
6
+ static description: string;
7
+ static enableJsonFlag: boolean;
8
+ static examples: string[];
9
+ static flags: {
10
+ 'ad-group': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ campaign: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'campaign-group': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'date-from': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ 'date-to': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ 'entity-id': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ 'entity-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ 'event-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
+ fields: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ txn: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
+ user: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
+ 'user-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
+ page: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
23
+ 'page-size': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
24
+ };
25
+ run(): Promise<ChangeHistoryPage>;
26
+ }
27
+ export {};