Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2652,6 +2652,44 @@ These commands use the pinned SDK's `analytics.get` and `analytics.metrics`
methods and shared response types. A CLI release containing these commands and
an API deployment exposing Analytics are separate requirements.

### Audience profiles (beta)

Use a team API key with `campaigns:read` for reads or `campaigns:manage` for
changes. Project and client tokens cannot access team profiles. Profile writes
and import creation require beta enrollment; reads, cancellation and deletion
remain available after withdrawal. These commands are source additions pending
the matching SDK prerelease and API acceptance.

```sh
polymorfa campaign profiles list --limit 50
polymorfa campaign profiles list --cursor CURSOR
polymorfa campaign profiles get +14155550123
polymorfa campaign profiles upsert +14155550123 --input profile.json
polymorfa campaign profiles consent +14155550123 --limit 25
polymorfa campaign profiles import --csv contacts.csv --replay-key contacts-1
polymorfa campaign profiles imports get JOB_ID
polymorfa campaign profiles imports cancel JOB_ID
polymorfa campaign profiles delete +14155550123 --expected-revision 2
```

`profile.json` must contain `tags` and `attributes`. Include the latest
`expectedRevision` when replacing an existing profile. Optional `provenance`
and `consent` match the API body; assertions never override opt-outs. A stale
revision returns 409. Deletion preserves opt-outs and existing campaign recipients.

Imports accept at most 10 MiB and 10,000 rows. Use `--mapping mapping.json`
for explicit CSV columns, for example
`{"phone":"Phone","tags":"Tags","attributes":"Attributes"}`. Omitted mapping
uses the API's standard column names. Poll the job until `completed` or
`cancelled`. Cancellation leaves committed edits intact. Replay keys must be
reused only with the same CSV, mapping and credential.

`--tag` and `--phone-prefix` require API support not yet available (API #567).
They fail locally without sending a request; this CLI does not offer profile
filtering. Listing supports `--cursor` and `--limit`. `--json` preserves
API envelopes and pagination. The CLI keeps its existing SDK pin and uses a
local typed adapter until a matching SDK prerelease is published.

## Package release workflow

CLI development and release PRs target `main`. Nightly packages use the `dev` npm tag after matching staging API acceptance; stable packages follow production acceptance. Each package pins an exact accepted SDK version. See [the release guide](RELEASING.md).
18 changes: 10 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"verify": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build && npm pack --dry-run --ignore-scripts && npm run audit:prod"
},
"dependencies": {
"@modelcontextprotocol/server": "2.0.0",
"@modelcontextprotocol/server": "2.3.1",
"@napi-rs/keyring": "1.3.0",
"@oclif/core": "^4.13.5",
"@polymorfa/sdk": "0.1.0-dev.20261004024706",
Expand Down Expand Up @@ -332,6 +332,12 @@
},
"analytics": {
"description": "Read WhatsApp number activity and operational vitals."
},
"campaign:profiles": {
"description": "Manage customer-supplied team audience profiles (beta)."
},
"campaign:profiles:imports": {
"description": "Read and cancel audience profile CSV imports."
}
}
},
Expand Down
39 changes: 39 additions & 0 deletions src/commands/campaign/profiles/consent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {Args, Flags} from '@oclif/core'
import {BaseCommand} from '../../../base-command.js'
import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js'
import {profileResult} from '../../../services/audience-profiles.js'
import {terminalText} from '../../../developer/render.js'
export default class ProfilesConsent extends BaseCommand {
static override args = {
phone: Args.string({
required: true,
description: 'E.164 phone including +.',
}),
}
static override description =
'List one page of supplied campaign consent assertions. Assertions do not clear opt-outs. Requires campaigns:read.'
static override flags = {
cursor: Flags.string({description: 'Continue from this cursor.'}),
limit: Flags.integer({
min: 1,
max: 100,
description: 'Assertions per page (default 25).',
}),
}
async run(): Promise<void> {
const {args, flags} = await this.parse(ProfilesConsent)
const resource = new AudienceProfilesResource(
(await this.platformClient(flags)).raw,
)

const response = await resource.listConsent(args.phone, {
...(flags.cursor === undefined ? {} : {cursor: flags.cursor}),
...(flags.limit === undefined ? {} : {limit: flags.limit}),
})
this.writeValue(
profileResult(response),
terminalText(JSON.stringify(response.data)),
flags,
)
}
}
37 changes: 37 additions & 0 deletions src/commands/campaign/profiles/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {Args, Flags} from '@oclif/core'
import {BaseCommand} from '../../../base-command.js'
import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js'
import {profileResult} from '../../../services/audience-profiles.js'
export default class ProfilesDelete extends BaseCommand {
static override args = {
phone: Args.string({
required: true,
description: 'E.164 phone including +.',
}),
}
static override description =
'Delete a profile at its latest revision. Removes profile data and audience memberships; preserves opt-outs and campaign recipients. Requires campaigns:manage.'
static override flags = {
'expected-revision': Flags.integer({
min: 1,
required: true,
description: 'Latest profile revision. Stale revisions return 409.',
}),
}
async run(): Promise<void> {
const {args, flags} = await this.parse(ProfilesDelete)
const resource = new AudienceProfilesResource(
(await this.platformClient(flags)).raw,
)

const response = await resource.delete(
args.phone,
flags['expected-revision'],
)
this.writeValue(
profileResult(response),
`Deleted ${response.data.data.phone}; retained revision ${response.data.data.revision}.`,
flags,
)
}
}
31 changes: 31 additions & 0 deletions src/commands/campaign/profiles/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {Args} from '@oclif/core'
import {BaseCommand} from '../../../base-command.js'
import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js'
import {
profileResult,
renderProfile,
} from '../../../services/audience-profiles.js'
export default class ProfilesGet extends BaseCommand {
static override args = {
phone: Args.string({
required: true,
description: 'E.164 phone including +.',
}),
}
static override description =
'Get a team audience profile by E.164 phone. Requires campaigns:read.'

async run(): Promise<void> {
const {args, flags} = await this.parse(ProfilesGet)
const resource = new AudienceProfilesResource(
(await this.platformClient(flags)).raw,
)

const response = await resource.retrieve(args.phone)
this.writeValue(
profileResult(response),
renderProfile(response.data.data),
flags,
)
}
}
51 changes: 51 additions & 0 deletions src/commands/campaign/profiles/import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {Flags} from '@oclif/core'
import {BaseCommand} from '../../../base-command.js'
import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js'
import {
profileResult,
renderImport,
} from '../../../services/audience-profiles.js'
import {readJsonInput, readTextInput} from '../../../api/request.js'
import {importMapping} from '../../../services/audience-profiles.js'
export default class ProfilesImport extends BaseCommand {
static override description =
'Queue a profile CSV import (at most 10 MiB and 10,000 data rows). Requires campaigns:manage and beta enrollment. Reuse the replay key only with identical CSV, mapping and credential; poll imports get for progress.'
static override flags = {
csv: Flags.string({
required: true,
description: 'UTF-8 CSV file or stdin (-).',
}),
'replay-key': Flags.string({
required: true,
description:
'Stable import identity, 1-128 letters, digits, dots, underscores, colons or hyphens.',
}),
mapping: Flags.string({
description: 'JSON file mapping profile fields to CSV header names.',
}),
}
async run(): Promise<void> {
const {flags} = await this.parse(ProfilesImport)
if (flags.csv === '-' && flags.mapping === '-')
throw new Error('CSV and mapping cannot both read stdin.')
const csv = await readTextInput(flags.csv)
const mapping =
flags.mapping === undefined
? undefined
: importMapping(await readJsonInput(flags.mapping))
const resource = new AudienceProfilesResource(
(await this.platformClient(flags)).raw,
)

const response = await resource.createImport({
csv,
replayKey: flags['replay-key'],
...(mapping === undefined ? {} : {mapping}),
})
this.writeValue(
profileResult(response),
renderImport(response.data.data),
flags,
)
}
}
27 changes: 27 additions & 0 deletions src/commands/campaign/profiles/imports/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {Args} from '@oclif/core'
import {BaseCommand} from '../../../../base-command.js'
import {AudienceProfilesResource} from '../../../../sdk/audience-profiles.js'
import {
profileResult,
renderImport,
} from '../../../../services/audience-profiles.js'
export default class ProfileImportsCancel extends BaseCommand {
static override args = {
job: Args.string({required: true, description: 'Import job ID.'}),
}
static override description =
'Cancel remaining import rows. Completed edits remain. Requires campaigns:manage; available after beta withdrawal.'
async run(): Promise<void> {
const {args, flags} = await this.parse(ProfileImportsCancel)
const resource = new AudienceProfilesResource(
(await this.platformClient(flags)).raw,
)

const response = await resource.cancelImport(args.job)
this.writeValue(
profileResult(response),
renderImport(response.data.data),
flags,
)
}
}
27 changes: 27 additions & 0 deletions src/commands/campaign/profiles/imports/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {Args} from '@oclif/core'
import {BaseCommand} from '../../../../base-command.js'
import {AudienceProfilesResource} from '../../../../sdk/audience-profiles.js'
import {
profileResult,
renderImport,
} from '../../../../services/audience-profiles.js'
export default class ProfileImportsGet extends BaseCommand {
static override args = {
job: Args.string({required: true, description: 'Import job ID.'}),
}
static override description =
'Read an aggregate profile import report. Requires campaigns:read.'
async run(): Promise<void> {
const {args, flags} = await this.parse(ProfileImportsGet)
const resource = new AudienceProfilesResource(
(await this.platformClient(flags)).raw,
)

const response = await resource.retrieveImport(args.job)
this.writeValue(
profileResult(response),
renderImport(response.data.data),
flags,
)
}
}
49 changes: 49 additions & 0 deletions src/commands/campaign/profiles/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {PolymorfaValidationError} from '@polymorfa/sdk'
import {Flags} from '@oclif/core'
import {BaseCommand} from '../../../base-command.js'
import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js'
import {
profileResult,
renderProfilePage,
} from '../../../services/audience-profiles.js'
export default class ProfilesList extends BaseCommand {
static override description =
'List one page of team audience profiles. Requires a team key with campaigns:read. Tag and phone prefix filters are unavailable.'
static override flags = {
cursor: Flags.string({description: 'Continue from this cursor.'}),
limit: Flags.integer({
min: 1,
max: 100,
description: 'Profiles per page (default 25).',
}),
tag: Flags.string({
description:
'Unavailable: requires API support not yet available (API #567).',
}),
'phone-prefix': Flags.string({
description:
'Unavailable: requires API support not yet available (API #567).',
}),
}
async run(): Promise<void> {
const {flags} = await this.parse(ProfilesList)
if (flags.tag !== undefined || flags['phone-prefix'] !== undefined)
throw new PolymorfaValidationError(
'--tag and --phone-prefix require API support not yet available (API #567).',
{code: 'audience_profile_filters_unavailable'},
)
const resource = new AudienceProfilesResource(
(await this.platformClient(flags)).raw,
)

const response = await resource.list({
...(flags.cursor === undefined ? {} : {cursor: flags.cursor}),
...(flags.limit === undefined ? {} : {limit: flags.limit}),
})
this.writeValue(
profileResult(response),
renderProfilePage(response.data),
flags,
)
}
}
Loading
Loading