@salesforce/afv-skills 1.57.0 → 1.58.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/afv-skills",
3
- "version": "1.57.0",
3
+ "version": "1.58.0",
4
4
  "description": "Salesforce skills for Agentforce Vibes",
5
5
  "license": "CC-BY-NC-4.0",
6
6
  "files": [
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: experience-ui-bundle-project-generate
3
- description: "Generates a minimal, ready-to-develop SFDX starter project from template instead of hand-scaffolding files. Use this skill when starting a brand-new Salesforce UI bundle app (React or Angular) and the initial project must be scaffolded — trigger phrases include create, start, or scaffold a new UI bundle app, generate a starter project, or use a prebuilt/starter template. DO NOT TRIGGER when: editing, styling, or adding pages or components to an EXISTING app (use experience-ui-bundle-frontend-generate); configuring ui-bundle.json or metadata files (use experience-ui-bundle-metadata-generate); deploying to an org (use experience-ui-bundle-deploy); or when the user explicitly says they want to hand-scaffold from scratch."
3
+ description: "Generates a minimal, ready-to-develop SFDX starter project from template instead of hand-scaffolding files. Use this skill when starting a brand-new Salesforce UI bundle app (React or Angular) and the initial project must be scaffolded — trigger phrases include create, start, or scaffold a new UI bundle app, generate a starter project, or use a prebuilt/starter template. DO NOT TRIGGER when: editing, styling, or adding pages or components to an EXISTING app (use experience-ui-bundle-frontend-generate); configuring ui-bundle.json or metadata files (use experience-ui-bundle-metadata-generate); deploying to an org (use experience-ui-bundle-deploy); when the user explicitly says they want to hand-scaffold from scratch; or when creating a brand-new standalone Salesforce project that also needs full setup — relocating the session, connecting an org, setting the default, and enabling source tracking (use dx-project-create in the salesforce-development plugin)."
4
4
  metadata:
5
5
  version: "1.2"
6
6
  domains: ["Experience"]
@@ -42,39 +42,22 @@ Once the user picks, carry the chosen `--template` flag into Step 2.
42
42
 
43
43
  ## Step 2: Generate the project into the target root
44
44
 
45
- The project contents must land **directly at the target root `$DEST`** — so `sfdx-project.json` sits at `$DEST/sfdx-project.json`, with no extra wrapper subfolder. `sf template generate project` always nests its output under a `--name` subfolder, so generate into the `$DEST` dir, then move the contents from the subfolder up into `$DEST`, overwriting anything already there on conflict. Remove the empty subfolder at the end.
45
+ The project contents must land **directly at the target root `$DEST`** — so `sfdx-project.json` sits at `$DEST/sfdx-project.json`, with no extra wrapper subfolder. `sf template generate project` always nests its output under a `--name` subfolder, so `<SKILL_DIR>/scripts/generate-project.mjs` generates into `$DEST`, flattens the subfolder's contents up into `$DEST` (overwriting anything already there on conflict), and removes the now-empty subfolder — all through Node's `fs`/`child_process` APIs, so it runs the same way on Windows cmd/PowerShell as it does on macOS/Linux/Git Bash.
46
46
 
47
47
  - `<SKILL_DIR>` = the absolute path to **this skill's own directory** — the folder containing this `SKILL.md`; resolve it from the skill path in context
48
48
  - **`$NAME`** — the project name (alphanumerical only — no spaces, hyphens, underscores, or special characters). Ask the user for it. It also names the UI bundle, so it shows up inside the project.
49
49
  - **`$DEST`** — the target root directory the contents land in (use `.` for the current directory).
50
+ - **`$TEMPLATE`** — the `--template` flag value from the framework reference chosen in Step 1 (e.g. `reactinternalapp`).
50
51
 
51
- ```sh
52
- NAME=MyApp # project name the user chose; also names the UI bundle
53
- DEST=. # target root directory (the contents land directly here, no NAME/ wrapper)
54
-
55
- # the --template flag from the framework reference chosen in Step 1
56
- TEMPLATE=reactinternalapp # example placeholder — replace with the flag from your Step-1 reference
57
-
58
- mkdir -p "$DEST"
59
- sf template generate project --name "$NAME" --template "$TEMPLATE" --output-dir "$DEST"
60
-
61
- # Flatten the generated $DEST/$NAME contents up into $DEST (see <SKILL_DIR>/scripts/flatten-project.mjs).
62
- # Use the absolute skill-dir path — a relative ./scripts/ would resolve against $DEST, not the skill.
63
- node "<SKILL_DIR>/scripts/flatten-project.mjs" "$DEST/$NAME" "$DEST"
64
- rm -rf "$DEST/$NAME"
65
- ```
66
-
67
- > `<SKILL_DIR>/scripts/flatten-project.mjs` moves every generated entry (incl. dotfiles) into `$DEST`, overwriting any existing file/dir of any type on conflict while preserving unrelated files the user already had in `$DEST`. The per-entry `rmSync` + `renameSync` is what guarantees the template's files win on conflict (including a file-vs-directory type mismatch).
68
-
69
- ### Verify
70
-
71
- After generation, confirm the contents landed at the root (not in a `$NAME/` subfolder):
52
+ Run the script with the actual, literal values substituted for `$NAME`, `$DEST`, and `$TEMPLATE` — do not use shell variable assignment/interpolation (`NAME=...` / `$NAME` / `%NAME%` / `$env:NAME`) since that syntax differs across bash, cmd, and PowerShell and this command must work in all three:
72
53
 
73
54
  ```sh
74
- test -f "$DEST/sfdx-project.json" && echo "OK: project root landed" || echo "FAILED"
55
+ node "<SKILL_DIR>/scripts/generate-project.mjs" "<name>" "<dest>" "<template>"
75
56
  ```
76
57
 
77
- `sfdx-project.json` must sit at `$DEST/sfdx-project.json`. The project also contains `package.json`, `force-app/main/default/uiBundles/$NAME/` (the UI bundle), `scripts/`, `config/`, and `README.md`. See the framework reference from Step 1 for the specific bundle contents. If `sfdx-project.json` is missing or is one level down in `$DEST/$NAME/`, the flatten did not run — re-check before continuing.
58
+ The script prints `OK: project root landed at <dest> (...)` and exits 0 on success. It exits non-zero (with a clear stderr message) if `sf template generate project` fails, the generated project has no `sfdx-project.json`, or the flatten didn't leave a valid project root — stop and surface the failure rather than continuing.
59
+
60
+ `sfdx-project.json` must sit at `$DEST/sfdx-project.json`. The project also contains `package.json`, `force-app/main/default/uiBundles/$NAME/` (the UI bundle), `scripts/`, `config/`, and `README.md`. See the framework reference from Step 1 for the specific bundle contents.
78
61
 
79
62
  ## Step 3: Install dependencies (you do this — do NOT hand off uninstalled)
80
63
 
@@ -84,26 +67,17 @@ There are **multiple** `package.json` files, each needing its own install:
84
67
  - the **project root** (`$DEST/package.json`), and
85
68
  - the **UI bundle** dir under `$DEST/force-app/main/default/uiBundles/$NAME/` — this holds the toolchain the preview server loads, so it must have `node_modules` too.
86
69
 
87
- ```sh
88
- # 1. project root ($DEST was set in Step 2)
89
- ( cd "$DEST" && npm install )
70
+ `<SKILL_DIR>/scripts/install-deps.mjs` runs `npm install` for the root and for every UI bundle that has a `package.json` (via Node's `child_process` with an explicit `cwd` — no `cd &&` shell chaining), then verifies `node_modules` landed everywhere it installed:
90
71
 
91
- # 2. each UI bundle
92
- for b in "$DEST"/force-app/main/default/uiBundles/*/; do
93
- [ -f "$b/package.json" ] && ( cd "$b" && npm install )
94
- done
72
+ ```sh
73
+ node "<SKILL_DIR>/scripts/install-deps.mjs" "<dest>"
95
74
  ```
96
75
 
97
- > First-run install of the bundle is the heavy step; expect a short wait. If an install fails, surface it — don't hand off a half-installed project.
76
+ Substitute the literal `$DEST` value from Step 2 for `<dest>`. The script prints `OK: all dependencies installed.` and exits 0 on success; it exits non-zero with a listed summary of which install(s) failed otherwise. First-run install of the bundle is the heavy step; expect a short wait. If an install fails, surface it — don't hand off a half-installed project.
98
77
 
99
78
  ## Step 4: Confirm and hand off
100
79
 
101
- Verify the project landed and is installed:
102
-
103
- ```sh
104
- ls "$DEST" # sfdx-project.json, package.json, force-app/, scripts/, README.md ...
105
- ls "$DEST"/force-app/main/default/uiBundles/*/node_modules >/dev/null && echo "bundle deps installed"
106
- ```
80
+ `install-deps.mjs` already prints a verification summary (root + each UI bundle's `node_modules`) as part of Step 3. To look around the generated project yourself, use your own file-listing/read tools rather than a shell `ls` — that works identically regardless of the underlying OS shell.
107
81
 
108
82
  The project is now ready to develop and deploy. If there's a `README.md` in the template, take a look at it to see if there is any extra step or guidance for the user.
109
83
 
@@ -10,24 +10,46 @@
10
10
  // — only the paths the template ships get replaced. The per-entry rmSync + renameSync is what
11
11
  // guarantees the template's files win on conflict (including a file-vs-directory type mismatch).
12
12
  // Requires Node ≥ 16.7 for rmSync.
13
+ //
14
+ // Always invoke via `node` (never as a bare executable) so this works on Windows cmd/PowerShell
15
+ // as well as macOS/Linux/Git Bash. Uses fs/path APIs only — no shell-out — so path separators and
16
+ // move semantics are correct on every OS.
13
17
 
14
18
  import fs from "node:fs";
15
19
  import path from "node:path";
20
+ import { pathToFileURL } from "node:url";
16
21
 
17
- const [src, dest] = process.argv.slice(2);
22
+ export function flattenProject(src, dest) {
23
+ if (!fs.existsSync(path.join(src, "sfdx-project.json"))) {
24
+ throw new Error("generated project has no sfdx-project.json: " + src);
25
+ }
18
26
 
19
- if (!src || !dest) {
20
- console.error("usage: node <skill_dir>/scripts/flatten-project.mjs <srcDir> <destDir>");
21
- process.exit(1);
27
+ for (const entry of fs.readdirSync(src)) {
28
+ const target = path.join(dest, entry);
29
+ fs.rmSync(target, { recursive: true, force: true });
30
+ fs.renameSync(path.join(src, entry), target);
31
+ }
22
32
  }
23
33
 
24
- if (!fs.existsSync(path.join(src, "sfdx-project.json"))) {
25
- console.error("generated project has no sfdx-project.json: " + src);
26
- process.exit(1);
34
+ function main() {
35
+ const [src, dest] = process.argv.slice(2);
36
+
37
+ if (!src || !dest) {
38
+ console.error("usage: node <skill_dir>/scripts/flatten-project.mjs <srcDir> <destDir>");
39
+ process.exit(1);
40
+ }
41
+
42
+ try {
43
+ flattenProject(src, dest);
44
+ } catch (err) {
45
+ console.error(err.message);
46
+ process.exit(1);
47
+ }
27
48
  }
28
49
 
29
- for (const entry of fs.readdirSync(src)) {
30
- const target = path.join(dest, entry);
31
- fs.rmSync(target, { recursive: true, force: true });
32
- fs.renameSync(path.join(src, entry), target);
50
+ // Only run as a CLI when invoked directly (not when imported by generate-project.mjs).
51
+ // pathToFileURL handles Windows drive letters/backslashes correctly (a manual
52
+ // `file://${path}` string would not).
53
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
54
+ main();
33
55
  }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // Generate a `sf template generate project` starter and flatten it so the
3
+ // contents land directly at the target root (no `<name>/` wrapper subfolder).
4
+ //
5
+ // Usage: node <skill_dir>/scripts/generate-project.mjs <name> <dest> <template>
6
+ // <name> — project name passed to `sf template generate project --name`
7
+ // <dest> — target root directory the contents should land in
8
+ // (so sfdx-project.json sits at <dest>/sfdx-project.json)
9
+ // <template> — the `--template` flag value from the chosen framework reference
10
+ //
11
+ // Always invoke via `node` (never as a bare executable, and never wrap this
12
+ // in a bash/cmd/PowerShell script) so this works on Windows cmd/PowerShell
13
+ // as well as macOS/Linux/Git Bash. Pass <name>/<dest>/<template> as literal
14
+ // argv values — do not rely on shell variable assignment/interpolation
15
+ // (`$VAR` / `%VAR%` / `$env:VAR` differ per shell).
16
+ //
17
+ // Runs `sf` via spawnSync with shell:true so the platform's own shell
18
+ // resolves the CLI shim correctly (on Windows, globally-installed CLIs are
19
+ // usually `.cmd`/`.ps1` wrappers that spawn() cannot find without a shell).
20
+ //
21
+ // Exit 0 on success (project landed at <dest>).
22
+ // Exit 1 if `sf template generate project` fails, the generated project has
23
+ // no sfdx-project.json, or the flatten didn't result in a valid project root.
24
+
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { spawnSync } from "node:child_process";
28
+ import { flattenProject } from "./flatten-project.mjs";
29
+
30
+ const [name, dest, template] = process.argv.slice(2);
31
+
32
+ function fail(message) {
33
+ console.error(message);
34
+ process.exit(1);
35
+ }
36
+
37
+ if (!name || !dest || !template) {
38
+ fail("usage: node <skill_dir>/scripts/generate-project.mjs <name> <dest> <template>");
39
+ }
40
+
41
+ fs.mkdirSync(dest, { recursive: true });
42
+
43
+ const result = spawnSync(
44
+ "sf",
45
+ ["template", "generate", "project", "--name", name, "--template", template, "--output-dir", dest],
46
+ { stdio: "inherit", shell: true },
47
+ );
48
+
49
+ if (result.error) {
50
+ fail(`ERROR: failed to run "sf template generate project": ${result.error.message}`);
51
+ }
52
+ if (result.status !== 0) {
53
+ process.exit(result.status ?? 1);
54
+ }
55
+
56
+ const srcDir = path.join(dest, name);
57
+
58
+ if (!fs.existsSync(path.join(srcDir, "sfdx-project.json"))) {
59
+ fail(`ERROR: generated project has no sfdx-project.json: ${srcDir}`);
60
+ }
61
+
62
+ try {
63
+ flattenProject(srcDir, dest);
64
+ } catch (err) {
65
+ fail(`ERROR: ${err.message}`);
66
+ }
67
+
68
+ // Remove the now-empty (or leftover) generated subfolder.
69
+ fs.rmSync(srcDir, { recursive: true, force: true });
70
+
71
+ const destProjectFile = path.join(dest, "sfdx-project.json");
72
+ if (!fs.existsSync(destProjectFile)) {
73
+ fail(`FAILED: sfdx-project.json not found at ${destProjectFile} — flatten did not run correctly`);
74
+ }
75
+
76
+ console.log(`OK: project root landed at ${dest} (${destProjectFile})`);
77
+ process.exit(0);
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // Install dependencies for a generated UI bundle starter project: the
3
+ // project root `package.json`, plus each `package.json` under
4
+ // force-app/main/default/uiBundles/*/ (the toolchain the preview server
5
+ // loads). Then verify node_modules landed everywhere it was installed.
6
+ //
7
+ // Usage: node <skill_dir>/scripts/install-deps.mjs <dest>
8
+ // <dest> — the project root (same value passed to generate-project.mjs)
9
+ //
10
+ // Always invoke via `node` (never as a bare executable, and never wrap this
11
+ // in a bash/cmd/PowerShell script) so this works on Windows cmd/PowerShell
12
+ // as well as macOS/Linux/Git Bash. Runs `npm install` via spawnSync with an
13
+ // explicit `cwd` (no `cd &&` subshell chaining) and shell:true so the
14
+ // platform's own shell resolves the `npm` CLI shim correctly.
15
+ //
16
+ // Exit 0 if every install succeeded (or there was nothing to install).
17
+ // Exit 1 with a summary of which installs failed — surface it, don't hand
18
+ // off a half-installed project.
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { spawnSync } from "node:child_process";
23
+
24
+ const [dest] = process.argv.slice(2);
25
+
26
+ if (!dest) {
27
+ console.error("usage: node <skill_dir>/scripts/install-deps.mjs <dest>");
28
+ process.exit(1);
29
+ }
30
+
31
+ function npmInstall(cwd) {
32
+ console.log(`Running "npm install" in ${cwd} ...`);
33
+ const result = spawnSync("npm", ["install"], { cwd, stdio: "inherit", shell: true });
34
+ if (result.error) {
35
+ return `${cwd}: ${result.error.message}`;
36
+ }
37
+ if (result.status !== 0) {
38
+ return `${cwd}: npm install exited with code ${result.status}`;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ const failures = [];
44
+
45
+ // 1. Project root.
46
+ if (fs.existsSync(path.join(dest, "package.json"))) {
47
+ const failure = npmInstall(dest);
48
+ if (failure) failures.push(failure);
49
+ } else {
50
+ console.error(`ERROR: no package.json found at project root: ${dest}`);
51
+ failures.push(`${dest}: missing package.json`);
52
+ }
53
+
54
+ // 2. Each UI bundle.
55
+ const bundlesDir = path.join(dest, "force-app", "main", "default", "uiBundles");
56
+ const bundleDirs = fs.existsSync(bundlesDir)
57
+ ? fs
58
+ .readdirSync(bundlesDir, { withFileTypes: true })
59
+ .filter((entry) => entry.isDirectory())
60
+ .map((entry) => path.join(bundlesDir, entry.name))
61
+ : [];
62
+
63
+ for (const bundleDir of bundleDirs) {
64
+ if (fs.existsSync(path.join(bundleDir, "package.json"))) {
65
+ const failure = npmInstall(bundleDir);
66
+ if (failure) failures.push(failure);
67
+ }
68
+ }
69
+
70
+ // 3. Verify node_modules landed everywhere it was installed.
71
+ console.log("\nVerifying installs:");
72
+ let allInstalled = true;
73
+ if (fs.existsSync(path.join(dest, "package.json"))) {
74
+ const ok = fs.existsSync(path.join(dest, "node_modules"));
75
+ console.log(` ${ok ? "OK" : "MISSING"}: ${path.join(dest, "node_modules")}`);
76
+ allInstalled = allInstalled && ok;
77
+ }
78
+ for (const bundleDir of bundleDirs) {
79
+ if (fs.existsSync(path.join(bundleDir, "package.json"))) {
80
+ const ok = fs.existsSync(path.join(bundleDir, "node_modules"));
81
+ console.log(` ${ok ? "OK" : "MISSING"}: ${path.join(bundleDir, "node_modules")}`);
82
+ allInstalled = allInstalled && ok;
83
+ }
84
+ }
85
+
86
+ if (failures.length > 0 || !allInstalled) {
87
+ console.error("\nFAILED: one or more installs did not complete:");
88
+ for (const failure of failures) {
89
+ console.error(` - ${failure}`);
90
+ }
91
+ process.exit(1);
92
+ }
93
+
94
+ console.log("\nOK: all dependencies installed.");
95
+ process.exit(0);
@@ -13,6 +13,7 @@ metadata:
13
13
  - "service-omni-base-settings-configure"
14
14
  - "service-omni-channel-inventory-analyze"
15
15
  - "service-omni-command-center-analyze"
16
+ - "service-omni-command-center-configure"
16
17
  - "service-omni-permission-set-assign"
17
18
  - "service-omni-presence-status-deploy"
18
19
  - "service-omni-presence-user-config-deploy"
@@ -8,6 +8,7 @@ metadata:
8
8
  minApiVersion: "66.0"
9
9
  relatedSkills:
10
10
  - "service-omni-channel-setup-coordinate"
11
+ - "service-omni-command-center-configure"
11
12
  - "service-omni-supervisor-config-deploy"
12
13
  accessCheck:
13
14
  - type: license
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: service-omni-command-center-configure
3
+ description: "Use to enable Command Center for Service V2 and configure its supported supervisor controls through the OmniChannel Settings Metadata API while preserving unrelated settings. Requires explicit apply consent, refuses production customer orgs, and verifies the seeded V2 page and tab. TRIGGER when: enable Command Center V2, configure conversation monitoring, enable agent sneak peek, enable customer sneak peek, configure whisper messaging, or enable queues and skills actions. Do not use to assign supervisor permissions or configure classic OmniSupervisorConfig metadata."
4
+ allowed-tools: Bash Read Write Edit Grep Glob
5
+ metadata:
6
+ version: "1.0"
7
+ domains: ["Service"]
8
+ minApiVersion: "69.0"
9
+ relatedSkills:
10
+ - "service-omni-base-settings-configure"
11
+ - "service-omni-channel-setup-coordinate"
12
+ - "service-omni-command-center-analyze"
13
+ - "service-omni-supervisor-config-deploy"
14
+ accessCheck:
15
+ - type: license
16
+ value: ServiceCloud
17
+ cliTools:
18
+ - tool: ["jq"]
19
+ semver: ">=1.6"
20
+ - tool: ["python3"]
21
+ semver: ">=3.8"
22
+ - tool: ["sf"]
23
+ semver: ">=2.139.6"
24
+ ---
25
+
26
+ # service-omni-command-center-configure
27
+
28
+ Enable Command Center for Service V2 through `Settings:OmniChannel` and optionally configure the five supervisor controls exposed by the same Metadata API contract. The writer retrieves the org's complete settings document, changes only requested elements, deploys that preserved document, retrieves it again, and verifies both the values and the platform-created `CommandCenterForServiceV2_L` page and V2 tab.
29
+
30
+ The skill fails closed with `platform_api_unavailable` when the retrieved settings document does not expose `enableCommandCenterForServiceV2`.
31
+
32
+ Run `service-omni-base-settings-configure` first so the Omni foundation is enabled. If the org does not support Command Center V2, use `service-omni-supervisor-config-deploy` for the classic supervisor configuration instead.
33
+
34
+ For an end-to-end setup, `service-omni-channel-setup-coordinate` invokes this skill only when `OMNI_COMMAND_CENTER_V2=1`; the coordinator's default supervisor path remains unchanged.
35
+
36
+ ## Inputs
37
+
38
+ ```bash
39
+ bash scripts/configure-and-report.sh plan <org-alias> [options]
40
+ bash scripts/configure-and-report.sh run <org-alias> --apply [options]
41
+ ```
42
+
43
+ Options:
44
+
45
+ | Option | Metadata element | Values |
46
+ |---|---|---|
47
+ | `--conversation-monitoring` | `enableConversationMonitoring` | `true` or `false` |
48
+ | `--agent-sneak-peek` | `enableAgentSneakPeek` | `true` or `false` |
49
+ | `--customer-sneak-peek` | `enableClientSneakPeek` | `true` or `false` |
50
+ | `--whisper-messaging` | `enableWhisperMessaging` | `true` or `false` |
51
+ | `--queues-and-skills` | `enableSkillsAndQueueActions` | `true` or `false` |
52
+
53
+ `enableCommandCenterForServiceV2=true` is always requested. Unspecified supervisor controls are preserved exactly as retrieved. The skill intentionally does not disable V2 because disabling an org-level supervisor experience is a materially different operation.
54
+
55
+ ## Safety
56
+
57
+ - `plan` is read-only and never deploys.
58
+ - `run` requires the literal `--apply` flag before any mutation.
59
+ - Writes are allowed only when `IsSandbox=true`, `TrialExpirationDate` is non-null, or `OrganizationType` is `Developer Edition` or `Base Edition`. There is no production override.
60
+ - `Settings` is a whole-document Metadata API type. Never deploy a partial or hardcoded `OmniChannel.settings-meta.xml`; doing so can reset unrelated Omni settings.
61
+ - The target org must have Enhanced Omni-Channel and the Command Center for Service V2 release gater. The API field is also permission-gated by `Customize Application`.
62
+
63
+ ## Behavior
64
+
65
+ 1. Authenticate and retrieve `Settings:OmniChannel` at API v66.
66
+ 2. Confirm the V2 field is present. If it is absent, return `blocked` with `reason_code=platform_api_unavailable`; do not attempt a deploy.
67
+ 3. Build a requested-value set containing V2=`true` plus only the optional controls explicitly supplied by the caller.
68
+ 4. In `plan`, return `action_needed` or `reused` without writing.
69
+ 5. In `run`, enforce `--apply` and the non-production guard, mutate a private copy of the retrieved document, and deploy it.
70
+ 6. Retrieve the settings again and require every requested value to match.
71
+ 7. Require both the seeded `FlexiPage` (`CommandCenterForServiceV2_L`) and V2 `TabDefinition` to be observable. Missing or unreadable provisioning evidence returns `blocked`; the skill never guesses.
72
+
73
+ The platform's ON-transition hook is idempotent: it creates missing profile tab configuration and seeds the baseline FlexiPage only when needed. Existing page customizations are not overwritten. If the preference is already true but its seed artifacts are missing, the skill returns `seed_incomplete`; it does not force a destructive OFF→ON cycle.
74
+
75
+ ## Output contract
76
+
77
+ The script emits one JSON object with:
78
+
79
+ - `status`: `configured`, `reused`, `action_needed`, or `blocked`
80
+ - `reason_code`: a stable reason such as `changes_required`, `already_configured`, `platform_api_unavailable`, `unsafe_target`, `deploy_failed`, `verification_failed`, or `seed_incomplete`
81
+ - `requested`, `before`, and `after`: setting values, with presence retained in the snapshots
82
+ - `safe_to_write`, `deploy_id`, and `verification`
83
+ - `manual_actions`: permission assignment and any required follow-up
84
+ - `blocking_issue`: populated only when blocked
85
+
86
+ Enabling the org preference does not grant user access. Assign a permission set containing `CommandCenterForServiceUser` to each intended supervisor, then run `service-omni-command-center-analyze <org> <supervisor>` to verify end-to-end readiness.
87
+
88
+ ## References
89
+
90
+ | File | When to read |
91
+ |---|---|
92
+ | `references/api-notes.md` | Metadata names, platform gates, seed behavior, and known limitations |
@@ -0,0 +1,50 @@
1
+ # Command Center for Service V2 API notes
2
+
3
+ ## Metadata contract
4
+
5
+ Core work item `W-24039822` and Core PR `#16874` expose these fields on `Settings:OmniChannel`:
6
+
7
+ | Metadata field | Product control |
8
+ |---|---|
9
+ | `enableCommandCenterForServiceV2` | Command Center for Service V2 |
10
+ | `enableConversationMonitoring` | Conversation monitoring |
11
+ | `enableAgentSneakPeek` | Agent sneak peek |
12
+ | `enableClientSneakPeek` | Customer sneak peek |
13
+ | `enableWhisperMessaging` | Whisper messaging |
14
+ | `enableSkillsAndQueueActions` | Queues and skills actions |
15
+
16
+ The V2 preference is Metadata API writable only when the target release contains that contract and the `OmniChannel.commandCenterForServiceV2Available` access check passes. That access check requires the feature gater and Enhanced Omni-Channel. Standard Omni orgs on the EOL extension cannot enable V2.
17
+
18
+ ## ON-transition side effects
19
+
20
+ Changing `enableCommandCenterForServiceV2` from false to true invokes the existing `CommandCenterForServiceV2OrgPreference` hook. The hook:
21
+
22
+ 1. inserts missing profile tab configurations for the V2 tab;
23
+ 2. checks for `FlexiPage.DeveloperName='CommandCenterForServiceV2_L'`;
24
+ 3. clones the platform seed only when that page is absent.
25
+
26
+ Disabling the preference does not delete the page. Re-enabling preserves an existing page. This skill enables but does not disable V2.
27
+
28
+ ## Verification signals
29
+
30
+ The writer verifies the settings by a fresh Metadata API retrieve and verifies provisioning through:
31
+
32
+ ```sql
33
+ SELECT Id FROM FlexiPage WHERE DeveloperName='CommandCenterForServiceV2_L'
34
+ ```
35
+
36
+ using Tooling API, and:
37
+
38
+ ```sql
39
+ SELECT Name FROM TabDefinition WHERE Name='standard-commandcenterforservicev2'
40
+ ```
41
+
42
+ using the data API. Query errors remain `unknown` and block a successful write claim.
43
+
44
+ ## User access
45
+
46
+ The org preference and its seed artifacts establish org-level readiness only. A supervisor also needs a permission set with `PermissionsCommandCenterForServiceUser=true`. This skill reports that follow-up but does not create users or permission sets.
47
+
48
+ ## Whole-document requirement
49
+
50
+ Salesforce Settings metadata is deployed as a complete document. The implementation retrieves the current document and updates it in place. Do not replace this approach with a static template: a static template can silently drop existing Omni settings that are outside this skill's scope.
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env bash
2
+ # Configure Command Center for Service V2 through a preserved OmniChannel Settings document.
3
+
4
+ set -euo pipefail
5
+
6
+ usage() {
7
+ echo '{"status":"blocked","reason_code":"invalid_arguments","blocking_issue":"Usage: bash configure-and-report.sh <plan|run> <org-alias> [--apply] [--conversation-monitoring true|false] [--agent-sneak-peek true|false] [--customer-sneak-peek true|false] [--whisper-messaging true|false] [--queues-and-skills true|false]"}' >&2
8
+ exit 1
9
+ }
10
+
11
+ [ "$#" -ge 2 ] || usage
12
+
13
+ MODE="$1"
14
+ ORG="$2"
15
+ shift 2
16
+
17
+ case "$MODE" in
18
+ plan|run) ;;
19
+ *) usage ;;
20
+ esac
21
+
22
+ APPLY=false
23
+ REQUESTED_JSON='{"enableCommandCenterForServiceV2":true}'
24
+ ASSIGNMENTS=("enableCommandCenterForServiceV2=true")
25
+
26
+ add_setting() {
27
+ local metadata_name="$1"
28
+ local value="$2"
29
+ if [ "$value" != "true" ] && [ "$value" != "false" ]; then
30
+ jq -n --arg name "$metadata_name" --arg value "$value" \
31
+ '{status:"blocked",reason_code:"invalid_arguments",blocking_issue:("Expected true or false for " + $name + ", received: " + $value)}' >&2
32
+ exit 1
33
+ fi
34
+ REQUESTED_JSON=$(jq -c --arg name "$metadata_name" --argjson value "$value" '. + {($name):$value}' <<<"$REQUESTED_JSON")
35
+ ASSIGNMENTS+=("$metadata_name=$value")
36
+ }
37
+
38
+ while [ "$#" -gt 0 ]; do
39
+ case "$1" in
40
+ --apply)
41
+ APPLY=true
42
+ shift
43
+ ;;
44
+ --conversation-monitoring|--agent-sneak-peek|--customer-sneak-peek|--whisper-messaging|--queues-and-skills)
45
+ [ "$#" -ge 2 ] || usage
46
+ case "$1" in
47
+ --conversation-monitoring) FIELD="enableConversationMonitoring" ;;
48
+ --agent-sneak-peek) FIELD="enableAgentSneakPeek" ;;
49
+ --customer-sneak-peek) FIELD="enableClientSneakPeek" ;;
50
+ --whisper-messaging) FIELD="enableWhisperMessaging" ;;
51
+ --queues-and-skills) FIELD="enableSkillsAndQueueActions" ;;
52
+ esac
53
+ add_setting "$FIELD" "$2"
54
+ shift 2
55
+ ;;
56
+ *) usage ;;
57
+ esac
58
+ done
59
+
60
+ if [ "$MODE" = "plan" ] && [ "$APPLY" = "true" ]; then
61
+ jq -n '{status:"blocked",reason_code:"invalid_arguments",blocking_issue:"--apply is not valid in plan mode."}' >&2
62
+ exit 1
63
+ fi
64
+
65
+ if [ "$MODE" = "run" ] && [ "$APPLY" != "true" ]; then
66
+ jq -n --argjson requested "$REQUESTED_JSON" \
67
+ '{status:"blocked",reason_code:"apply_required",requested:$requested,safe_to_write:null,deploy_id:null,blocking_issue:"run mode requires explicit --apply consent; no org changes were made."}'
68
+ exit 1
69
+ fi
70
+
71
+ for dependency in sf jq python3; do
72
+ if ! command -v "$dependency" >/dev/null 2>&1; then
73
+ jq -n --arg dependency "$dependency" \
74
+ '{status:"blocked",reason_code:"missing_dependency",blocking_issue:("Required command is unavailable: " + $dependency)}' >&2
75
+ exit 1
76
+ fi
77
+ done
78
+
79
+ if ! sf org display --target-org "$ORG" --json >/dev/null 2>&1; then
80
+ jq -n --arg org "$ORG" \
81
+ '{status:"blocked",reason_code:"org_not_authenticated",blocking_issue:("Org alias is not authenticated: " + $org)}' >&2
82
+ exit 1
83
+ fi
84
+
85
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
86
+ DOCUMENT_TOOL="$SCRIPT_DIR/settings_document.py"
87
+ WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/omni-command-center-configure.XXXXXX")"
88
+ trap 'rm -rf "$WORK_DIR"' EXIT
89
+
90
+ create_project() {
91
+ local directory="$1"
92
+ mkdir -p "$directory/force-app/main/default"
93
+ printf '%s\n' '{"packageDirectories":[{"path":"force-app","default":true}],"sourceApiVersion":"66.0"}' > "$directory/sfdx-project.json"
94
+ }
95
+
96
+ retrieve_settings() {
97
+ local directory="$1"
98
+ create_project "$directory"
99
+ (cd "$directory" && sf project retrieve start --target-org "$ORG" --metadata "Settings:OmniChannel" --json >/dev/null 2>&1)
100
+ }
101
+
102
+ settings_path() {
103
+ printf '%s/force-app/main/default/settings/OmniChannel.settings-meta.xml' "$1"
104
+ }
105
+
106
+ if ! retrieve_settings "$WORK_DIR/before"; then
107
+ jq -n --argjson requested "$REQUESTED_JSON" \
108
+ '{status:"blocked",reason_code:"retrieve_failed",requested:$requested,blocking_issue:"Could not retrieve Settings:OmniChannel. Verify org access and retry."}'
109
+ exit 1
110
+ fi
111
+
112
+ BEFORE_XML="$(settings_path "$WORK_DIR/before")"
113
+ if [ ! -f "$BEFORE_XML" ]; then
114
+ jq -n --argjson requested "$REQUESTED_JSON" \
115
+ '{status:"blocked",reason_code:"retrieve_failed",requested:$requested,blocking_issue:"The retrieve completed without an OmniChannel settings document."}'
116
+ exit 1
117
+ fi
118
+
119
+ if ! BEFORE_JSON=$(python3 "$DOCUMENT_TOOL" inspect "$BEFORE_XML" 2>/dev/null); then
120
+ jq -n --argjson requested "$REQUESTED_JSON" \
121
+ '{status:"blocked",reason_code:"invalid_settings_document",requested:$requested,blocking_issue:"The retrieved OmniChannel settings document could not be parsed."}'
122
+ exit 1
123
+ fi
124
+
125
+ API_PRESENT=$(jq -r '.settings.enableCommandCenterForServiceV2.present' <<<"$BEFORE_JSON")
126
+ if [ "$API_PRESENT" != "true" ]; then
127
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" \
128
+ '{status:"blocked",reason_code:"platform_api_unavailable",requested:$requested,before:$before,after:null,safe_to_write:null,deploy_id:null,verification:null,manual_actions:[{id:"WAIT_FOR_W_24039822",title:"Use a target release containing the W-24039822 Metadata API contract (Core PR #16874)."}],blocking_issue:"The target org does not expose enableCommandCenterForServiceV2 in Settings:OmniChannel; no deployment was attempted."}'
129
+ exit 1
130
+ fi
131
+
132
+ values_match() {
133
+ local snapshot="$1"
134
+ jq -ne --argjson requested "$REQUESTED_JSON" --argjson snapshot "$snapshot" '
135
+ all($requested | to_entries[];
136
+ (.key as $name | .value as $wanted |
137
+ $snapshot.settings[$name].present == true and $snapshot.settings[$name].value == $wanted)
138
+ )
139
+ ' >/dev/null
140
+ }
141
+
142
+ query_verification() {
143
+ local seed_json tab_json seed tab
144
+ seed_json=$(sf data query --target-org "$ORG" --use-tooling-api --json \
145
+ --query "SELECT Id FROM FlexiPage WHERE DeveloperName='CommandCenterForServiceV2_L'" 2>/dev/null || true)
146
+ if [ "$(jq -r '.status // 1' <<<"$seed_json" 2>/dev/null)" = "0" ]; then
147
+ if [ "$(jq -r '.result.totalSize // 0' <<<"$seed_json")" -gt 0 ]; then seed=true; else seed=false; fi
148
+ else
149
+ seed=unknown
150
+ fi
151
+
152
+ tab_json=$(sf data query --target-org "$ORG" --json \
153
+ --query "SELECT Name FROM TabDefinition WHERE Name='standard-commandcenterforservicev2' LIMIT 1" 2>/dev/null || true)
154
+ if [ "$(jq -r '.status // 1' <<<"$tab_json" 2>/dev/null)" = "0" ]; then
155
+ if [ "$(jq -r '.result.totalSize // 0' <<<"$tab_json")" -gt 0 ]; then tab=true; else tab=false; fi
156
+ else
157
+ tab=unknown
158
+ fi
159
+
160
+ jq -n --arg seed "$seed" --arg tab "$tab" \
161
+ '{seed_flexipage_present:$seed,v2_tab_present:$tab,complete:($seed == "true" and $tab == "true")}'
162
+ }
163
+
164
+ BEFORE_MATCH=false
165
+ if values_match "$BEFORE_JSON"; then BEFORE_MATCH=true; fi
166
+
167
+ if [ "$BEFORE_MATCH" = "true" ]; then
168
+ VERIFY_JSON=$(query_verification)
169
+ if [ "$(jq -r '.complete' <<<"$VERIFY_JSON")" != "true" ]; then
170
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" --argjson verification "$VERIFY_JSON" \
171
+ '{status:"blocked",reason_code:"seed_incomplete",requested:$requested,before:$before,after:$before,safe_to_write:null,deploy_id:null,verification:$verification,manual_actions:[{id:"REPAIR_V2_SEED",title:"The V2 preference is already enabled but its page or tab cannot be proven. Diagnose the platform seed before retrying; this skill will not force an OFF-to-ON cycle."}],blocking_issue:"Command Center V2 is enabled but its seed artifacts are missing or unreadable."}'
172
+ exit 1
173
+ fi
174
+
175
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" --argjson verification "$VERIFY_JSON" \
176
+ '{status:"reused",reason_code:"already_configured",requested:$requested,before:$before,after:$before,safe_to_write:null,deploy_id:null,verification:$verification,manual_actions:[{id:"ASSIGN_V2_PERMISSION",title:"Assign CommandCenterForServiceUser to each intended supervisor, then verify with service-omni-command-center-analyze."}],blocking_issue:null}'
177
+ exit 0
178
+ fi
179
+
180
+ if [ "$MODE" = "plan" ]; then
181
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" \
182
+ '{status:"action_needed",reason_code:"changes_required",requested:$requested,before:$before,after:null,safe_to_write:null,deploy_id:null,verification:null,manual_actions:[],blocking_issue:null}'
183
+ exit 0
184
+ fi
185
+
186
+ ORG_GUARD_JSON=$(sf data query --target-org "$ORG" --json \
187
+ --query "SELECT IsSandbox, TrialExpirationDate, OrganizationType FROM Organization LIMIT 1" 2>/dev/null || true)
188
+ if [ "$(jq -r '.status // 1' <<<"$ORG_GUARD_JSON" 2>/dev/null)" != "0" ]; then
189
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" \
190
+ '{status:"blocked",reason_code:"guard_query_failed",requested:$requested,before:$before,safe_to_write:null,deploy_id:null,blocking_issue:"Could not evaluate the non-production safe_to_write guard."}'
191
+ exit 1
192
+ fi
193
+
194
+ IS_SANDBOX=$(jq -r '.result.records[0].IsSandbox' <<<"$ORG_GUARD_JSON")
195
+ TRIAL_EXP=$(jq -r '.result.records[0].TrialExpirationDate // "null"' <<<"$ORG_GUARD_JSON")
196
+ ORG_TYPE=$(jq -r '.result.records[0].OrganizationType // ""' <<<"$ORG_GUARD_JSON")
197
+ SAFE_TO_WRITE=false
198
+ if [ "$IS_SANDBOX" = "true" ] || [ "$TRIAL_EXP" != "null" ] || [ "$ORG_TYPE" = "Developer Edition" ] || [ "$ORG_TYPE" = "Base Edition" ]; then
199
+ SAFE_TO_WRITE=true
200
+ fi
201
+
202
+ if [ "$SAFE_TO_WRITE" != "true" ]; then
203
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" \
204
+ '{status:"blocked",reason_code:"unsafe_target",requested:$requested,before:$before,safe_to_write:false,deploy_id:null,blocking_issue:"Refusing to configure Command Center V2 in a production customer org."}'
205
+ exit 1
206
+ fi
207
+
208
+ create_project "$WORK_DIR/deploy"
209
+ DEPLOY_XML="$(settings_path "$WORK_DIR/deploy")"
210
+ if ! python3 "$DOCUMENT_TOOL" render "$BEFORE_XML" "$DEPLOY_XML" "${ASSIGNMENTS[@]}" >/dev/null; then
211
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" \
212
+ '{status:"blocked",reason_code:"render_failed",requested:$requested,before:$before,safe_to_write:true,deploy_id:null,blocking_issue:"Could not construct the preserved OmniChannel settings document."}'
213
+ exit 1
214
+ fi
215
+
216
+ DEPLOY_JSON=$(cd "$WORK_DIR/deploy" && sf project deploy start --target-org "$ORG" --source-dir "$DEPLOY_XML" --json 2>/dev/null || true)
217
+ DEPLOY_SUCCESS=$(jq -r '.result.success // false' <<<"$DEPLOY_JSON" 2>/dev/null)
218
+ DEPLOY_STATUS=$(jq -r '.result.status // ""' <<<"$DEPLOY_JSON" 2>/dev/null)
219
+ DEPLOY_ID=$(jq -r '.result.id // ""' <<<"$DEPLOY_JSON" 2>/dev/null)
220
+ if [ "$DEPLOY_SUCCESS" != "true" ] || [ "$DEPLOY_STATUS" = "SucceededPartial" ]; then
221
+ DEPLOY_ERROR=$(jq -r '.result.details.componentFailures // [] | if type == "array" then map(.problem) | join("; ") else .problem // "" end' <<<"$DEPLOY_JSON" 2>/dev/null)
222
+ if [ -z "$DEPLOY_ERROR" ] || [ "$DEPLOY_ERROR" = "null" ]; then
223
+ DEPLOY_ERROR=$(jq -r '.message // .result.errorMessage // "Unknown deploy error"' <<<"$DEPLOY_JSON" 2>/dev/null)
224
+ fi
225
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" --arg id "$DEPLOY_ID" --arg error "$DEPLOY_ERROR" \
226
+ '{status:"blocked",reason_code:"deploy_failed",requested:$requested,before:$before,after:null,safe_to_write:true,deploy_id:(if $id == "" then null else $id end),verification:null,blocking_issue:("OmniChannel settings deployment failed: " + $error)}'
227
+ exit 1
228
+ fi
229
+
230
+ if ! retrieve_settings "$WORK_DIR/after"; then
231
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" --arg id "$DEPLOY_ID" \
232
+ '{status:"blocked",reason_code:"verification_failed",requested:$requested,before:$before,after:null,safe_to_write:true,deploy_id:(if $id == "" then null else $id end),verification:null,blocking_issue:"Deployment succeeded, but post-deploy Settings:OmniChannel retrieval failed."}'
233
+ exit 1
234
+ fi
235
+
236
+ AFTER_XML="$(settings_path "$WORK_DIR/after")"
237
+ if [ ! -f "$AFTER_XML" ] || ! AFTER_JSON=$(python3 "$DOCUMENT_TOOL" inspect "$AFTER_XML" 2>/dev/null); then
238
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" --arg id "$DEPLOY_ID" \
239
+ '{status:"blocked",reason_code:"verification_failed",requested:$requested,before:$before,after:null,safe_to_write:true,deploy_id:(if $id == "" then null else $id end),verification:null,blocking_issue:"Deployment succeeded, but the post-deploy settings document could not be inspected."}'
240
+ exit 1
241
+ fi
242
+
243
+ if ! values_match "$AFTER_JSON"; then
244
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" --argjson after "$AFTER_JSON" --arg id "$DEPLOY_ID" \
245
+ '{status:"blocked",reason_code:"verification_failed",requested:$requested,before:$before,after:$after,safe_to_write:true,deploy_id:(if $id == "" then null else $id end),verification:null,blocking_issue:"Deployment reported success, but one or more requested settings did not persist."}'
246
+ exit 1
247
+ fi
248
+
249
+ VERIFY_JSON=$(query_verification)
250
+ if [ "$(jq -r '.complete' <<<"$VERIFY_JSON")" != "true" ]; then
251
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" --argjson after "$AFTER_JSON" --argjson verification "$VERIFY_JSON" --arg id "$DEPLOY_ID" \
252
+ '{status:"blocked",reason_code:"seed_incomplete",requested:$requested,before:$before,after:$after,safe_to_write:true,deploy_id:(if $id == "" then null else $id end),verification:$verification,manual_actions:[{id:"INSPECT_V2_SEED",title:"The settings persisted, but the V2 page and tab were not both observable. Inspect the platform seed hook before claiming readiness."}],blocking_issue:"Command Center V2 settings persisted, but seed verification is incomplete."}'
253
+ exit 1
254
+ fi
255
+
256
+ jq -n --argjson requested "$REQUESTED_JSON" --argjson before "$BEFORE_JSON" --argjson after "$AFTER_JSON" --argjson verification "$VERIFY_JSON" --arg id "$DEPLOY_ID" \
257
+ '{status:"configured",reason_code:"configured_and_verified",requested:$requested,before:$before,after:$after,safe_to_write:true,deploy_id:(if $id == "" then null else $id end),verification:$verification,manual_actions:[{id:"ASSIGN_V2_PERMISSION",title:"Assign CommandCenterForServiceUser to each intended supervisor, then verify with service-omni-command-center-analyze."}],blocking_issue:null}'
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ """Inspect or safely mutate a retrieved OmniChannel Settings document."""
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ import xml.etree.ElementTree as ET
8
+ from pathlib import Path
9
+ from typing import List, Optional, Tuple
10
+
11
+
12
+ METADATA_NS = "http://soap.sforce.com/2006/04/metadata"
13
+ FIELDS = (
14
+ "enableCommandCenterForServiceV2",
15
+ "enableConversationMonitoring",
16
+ "enableAgentSneakPeek",
17
+ "enableClientSneakPeek",
18
+ "enableWhisperMessaging",
19
+ "enableSkillsAndQueueActions",
20
+ )
21
+
22
+
23
+ def parse_document(path: Path) -> Tuple[ET.ElementTree, ET.Element]:
24
+ tree = ET.parse(path)
25
+ return tree, tree.getroot()
26
+
27
+
28
+ def find_element(root: ET.Element, name: str) -> Optional[ET.Element]:
29
+ return root.find(f"{{{METADATA_NS}}}{name}")
30
+
31
+
32
+ def inspect(path: Path) -> int:
33
+ _, root = parse_document(path)
34
+ settings = {}
35
+ for name in FIELDS:
36
+ element = find_element(root, name)
37
+ text = (element.text or "").strip().lower() if element is not None else ""
38
+ settings[name] = {
39
+ "present": element is not None,
40
+ "value": text == "true" if text in {"true", "false"} else None,
41
+ }
42
+ print(json.dumps({"settings": settings}, separators=(",", ":")))
43
+ return 0
44
+
45
+
46
+ def render(source: Path, destination: Path, assignments: List[str]) -> int:
47
+ tree, root = parse_document(source)
48
+ requested = {}
49
+ for assignment in assignments:
50
+ if "=" not in assignment:
51
+ raise ValueError(f"Invalid assignment: {assignment}")
52
+ name, value = assignment.split("=", 1)
53
+ if name not in FIELDS:
54
+ raise ValueError(f"Unsupported setting: {name}")
55
+ if value not in {"true", "false"}:
56
+ raise ValueError(f"Invalid boolean for {name}: {value}")
57
+ requested[name] = value
58
+
59
+ if "enableCommandCenterForServiceV2" not in requested:
60
+ raise ValueError("enableCommandCenterForServiceV2 must be requested")
61
+
62
+ for name, value in requested.items():
63
+ element = find_element(root, name)
64
+ if element is None:
65
+ element = ET.SubElement(root, f"{{{METADATA_NS}}}{name}")
66
+ element.text = value
67
+
68
+ ET.register_namespace("", METADATA_NS)
69
+ destination.parent.mkdir(parents=True, exist_ok=True)
70
+ tree.write(destination, encoding="UTF-8", xml_declaration=True)
71
+ return 0
72
+
73
+
74
+ def main() -> int:
75
+ parser = argparse.ArgumentParser()
76
+ subparsers = parser.add_subparsers(dest="operation", required=True)
77
+
78
+ inspect_parser = subparsers.add_parser("inspect")
79
+ inspect_parser.add_argument("path", type=Path)
80
+
81
+ render_parser = subparsers.add_parser("render")
82
+ render_parser.add_argument("source", type=Path)
83
+ render_parser.add_argument("destination", type=Path)
84
+ render_parser.add_argument("assignments", nargs="+")
85
+
86
+ args = parser.parse_args()
87
+ try:
88
+ if args.operation == "inspect":
89
+ return inspect(args.path)
90
+ return render(args.source, args.destination, args.assignments)
91
+ except (ET.ParseError, OSError, ValueError) as error:
92
+ print(json.dumps({"error": str(error)}, separators=(",", ":")), file=sys.stderr)
93
+ return 1
94
+
95
+
96
+ if __name__ == "__main__":
97
+ raise SystemExit(main())
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+
12
+ SKILLS_ROOT = Path(__file__).resolve().parents[3]
13
+ SCRIPT = SKILLS_ROOT / "service-omni-command-center-configure/scripts/configure-and-report.sh"
14
+ DOCUMENT_TOOL = SKILLS_ROOT / "service-omni-command-center-configure/scripts/settings_document.py"
15
+
16
+
17
+ FAKE_SF = r"""#!/usr/bin/env bash
18
+ set -euo pipefail
19
+ args="$*"
20
+ [ -z "${FAKE_CALL_LOG:-}" ] || printf '%s\n' "$args" >> "$FAKE_CALL_LOG"
21
+
22
+ if [ "${1:-} ${2:-}" = "org display" ]; then
23
+ echo '{"status":0,"result":{"username":"fake@example.invalid"}}'
24
+ exit 0
25
+ fi
26
+
27
+ if [ "${1:-} ${2:-}" = "project retrieve" ]; then
28
+ mkdir -p force-app/main/default/settings
29
+ target=force-app/main/default/settings/OmniChannel.settings-meta.xml
30
+ if [ -f "$FAKE_STATE_DIR/deployed.xml" ]; then
31
+ cp "$FAKE_STATE_DIR/deployed.xml" "$target"
32
+ else
33
+ {
34
+ echo '<?xml version="1.0" encoding="UTF-8"?>'
35
+ echo '<OmniChannelSettings xmlns="http://soap.sforce.com/2006/04/metadata">'
36
+ echo ' <enableOmniChannel>true</enableOmniChannel>'
37
+ echo ' <unrelatedSetting>true</unrelatedSetting>'
38
+ if [ "${FAKE_API_PRESENT:-1}" = "1" ]; then
39
+ echo " <enableCommandCenterForServiceV2>${FAKE_V2:-false}</enableCommandCenterForServiceV2>"
40
+ echo " <enableConversationMonitoring>${FAKE_CONVERSATION:-false}</enableConversationMonitoring>"
41
+ echo " <enableAgentSneakPeek>${FAKE_AGENT_PEEK:-false}</enableAgentSneakPeek>"
42
+ echo " <enableClientSneakPeek>${FAKE_CLIENT_PEEK:-false}</enableClientSneakPeek>"
43
+ echo " <enableWhisperMessaging>${FAKE_WHISPER:-false}</enableWhisperMessaging>"
44
+ echo " <enableSkillsAndQueueActions>${FAKE_QUEUE_ACTIONS:-false}</enableSkillsAndQueueActions>"
45
+ fi
46
+ echo '</OmniChannelSettings>'
47
+ } > "$target"
48
+ fi
49
+ echo '{"status":0,"result":{"success":true}}'
50
+ exit 0
51
+ fi
52
+
53
+ if [ "${1:-} ${2:-}" = "project deploy" ]; then
54
+ if [ "${FAKE_DEPLOY_FAIL:-0}" = "1" ]; then
55
+ echo '{"status":1,"message":"simulated failure","result":{"success":false,"status":"Failed"}}'
56
+ exit 1
57
+ fi
58
+ source_path=""
59
+ while [ "$#" -gt 0 ]; do
60
+ if [ "$1" = "--source-dir" ]; then source_path="$2"; break; fi
61
+ shift
62
+ done
63
+ cp "$source_path" "$FAKE_STATE_DIR/deployed.xml"
64
+ touch "$FAKE_STATE_DIR/deployed"
65
+ echo '{"status":0,"result":{"success":true,"status":"Succeeded","id":"0AfFakeDeploy"}}'
66
+ exit 0
67
+ fi
68
+
69
+ if [ "${1:-} ${2:-}" = "data query" ]; then
70
+ if printf '%s' "$args" | grep -q 'FROM Organization'; then
71
+ if [ "${FAKE_PRODUCTION:-0}" = "1" ]; then
72
+ echo '{"status":0,"result":{"records":[{"IsSandbox":false,"TrialExpirationDate":null,"OrganizationType":"Enterprise Edition"}]}}'
73
+ else
74
+ echo '{"status":0,"result":{"records":[{"IsSandbox":true,"TrialExpirationDate":null,"OrganizationType":"Developer Edition"}]}}'
75
+ fi
76
+ elif printf '%s' "$args" | grep -q 'FROM FlexiPage'; then
77
+ if { [ -f "$FAKE_STATE_DIR/deployed" ] && [ "${FAKE_SEED_AFTER_DEPLOY:-1}" = "1" ]; } || [ "${FAKE_SEED_PRESENT:-0}" = "1" ]; then
78
+ echo '{"status":0,"result":{"records":[{"Id":"0M0Fake"}],"totalSize":1}}'
79
+ else
80
+ echo '{"status":0,"result":{"records":[],"totalSize":0}}'
81
+ fi
82
+ elif printf '%s' "$args" | grep -q 'FROM TabDefinition'; then
83
+ if { [ -f "$FAKE_STATE_DIR/deployed" ] && [ "${FAKE_TAB_AFTER_DEPLOY:-1}" = "1" ]; } || [ "${FAKE_TAB_PRESENT:-0}" = "1" ]; then
84
+ echo '{"status":0,"result":{"records":[{"Name":"standard-commandcenterforservicev2"}],"totalSize":1}}'
85
+ else
86
+ echo '{"status":0,"result":{"records":[],"totalSize":0}}'
87
+ fi
88
+ else
89
+ echo '{"status":0,"result":{"records":[],"totalSize":0}}'
90
+ fi
91
+ exit 0
92
+ fi
93
+
94
+ echo '{"status":1,"message":"unexpected fake sf invocation"}'
95
+ exit 1
96
+ """
97
+
98
+
99
+ def run_script(*args: str, **environment: str):
100
+ with tempfile.TemporaryDirectory(prefix="command-center-configure-") as directory:
101
+ root = Path(directory)
102
+ sf = root / "sf"
103
+ sf.write_text(FAKE_SF)
104
+ sf.chmod(0o755)
105
+ state = root / "state"
106
+ state.mkdir()
107
+ call_log = root / "calls.log"
108
+ env = dict(os.environ)
109
+ env.update(environment)
110
+ env["FAKE_STATE_DIR"] = str(state)
111
+ env["FAKE_CALL_LOG"] = str(call_log)
112
+ env["PATH"] = str(root) + os.pathsep + env.get("PATH", "")
113
+ result = subprocess.run(
114
+ ["bash", str(SCRIPT), *args],
115
+ capture_output=True,
116
+ text=True,
117
+ env=env,
118
+ cwd=str(SKILLS_ROOT),
119
+ )
120
+ calls = call_log.read_text() if call_log.exists() else ""
121
+ deployed = (state / "deployed.xml").read_text() if (state / "deployed.xml").exists() else ""
122
+ return result, calls, deployed
123
+
124
+
125
+ class CommandCenterConfigureContracts(unittest.TestCase):
126
+ def test_script_and_python_syntax(self):
127
+ shell = subprocess.run(["bash", "-n", str(SCRIPT)], capture_output=True, text=True)
128
+ self.assertEqual(shell.returncode, 0, shell.stderr)
129
+ compile_result = subprocess.run(
130
+ ["python3", "-c", f"import ast, pathlib; ast.parse(pathlib.Path({str(DOCUMENT_TOOL)!r}).read_text())"],
131
+ capture_output=True,
132
+ text=True,
133
+ env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
134
+ )
135
+ self.assertEqual(compile_result.returncode, 0, compile_result.stderr)
136
+
137
+ def test_run_requires_apply_before_any_sf_call(self):
138
+ result, calls, _ = run_script("run", "test-org")
139
+ self.assertEqual(result.returncode, 1)
140
+ self.assertEqual(json.loads(result.stdout)["reason_code"], "apply_required")
141
+ self.assertEqual(calls, "")
142
+
143
+ def test_plan_reports_changes_without_deploying(self):
144
+ result, calls, _ = run_script("plan", "test-org")
145
+ self.assertEqual(result.returncode, 0, result.stderr)
146
+ self.assertEqual(json.loads(result.stdout)["status"], "action_needed")
147
+ self.assertNotIn("project deploy", calls)
148
+
149
+ def test_missing_platform_field_fails_before_deploy(self):
150
+ result, calls, _ = run_script("run", "test-org", "--apply", FAKE_API_PRESENT="0")
151
+ self.assertEqual(result.returncode, 1)
152
+ self.assertEqual(json.loads(result.stdout)["reason_code"], "platform_api_unavailable")
153
+ self.assertNotIn("project deploy", calls)
154
+
155
+ def test_production_org_is_refused(self):
156
+ result, calls, _ = run_script("run", "test-org", "--apply", FAKE_PRODUCTION="1")
157
+ self.assertEqual(result.returncode, 1)
158
+ self.assertEqual(json.loads(result.stdout)["reason_code"], "unsafe_target")
159
+ self.assertNotIn("project deploy", calls)
160
+
161
+ def test_run_preserves_unrelated_and_unspecified_settings(self):
162
+ result, calls, deployed = run_script(
163
+ "run",
164
+ "test-org",
165
+ "--apply",
166
+ "--agent-sneak-peek",
167
+ "true",
168
+ )
169
+ payload = json.loads(result.stdout)
170
+ self.assertEqual(result.returncode, 0, result.stderr)
171
+ self.assertEqual(payload["status"], "configured")
172
+ self.assertIn("project deploy", calls)
173
+ self.assertIn("<unrelatedSetting>true</unrelatedSetting>", deployed)
174
+ self.assertIn("<enableConversationMonitoring>false</enableConversationMonitoring>", deployed)
175
+ self.assertIn("<enableAgentSneakPeek>true</enableAgentSneakPeek>", deployed)
176
+ self.assertIn("<enableCommandCenterForServiceV2>true</enableCommandCenterForServiceV2>", deployed)
177
+
178
+ def test_already_ready_is_reused_without_deploy(self):
179
+ result, calls, _ = run_script(
180
+ "run",
181
+ "test-org",
182
+ "--apply",
183
+ FAKE_V2="true",
184
+ FAKE_SEED_PRESENT="1",
185
+ FAKE_TAB_PRESENT="1",
186
+ )
187
+ self.assertEqual(result.returncode, 0, result.stderr)
188
+ self.assertEqual(json.loads(result.stdout)["status"], "reused")
189
+ self.assertNotIn("project deploy", calls)
190
+
191
+ def test_persisted_settings_without_seed_are_blocked(self):
192
+ result, _, _ = run_script(
193
+ "run",
194
+ "test-org",
195
+ "--apply",
196
+ FAKE_SEED_AFTER_DEPLOY="0",
197
+ FAKE_TAB_AFTER_DEPLOY="0",
198
+ )
199
+ self.assertEqual(result.returncode, 1)
200
+ payload = json.loads(result.stdout)
201
+ self.assertEqual(payload["reason_code"], "seed_incomplete")
202
+ self.assertEqual(payload["after"]["settings"]["enableCommandCenterForServiceV2"]["value"], True)
203
+
204
+
205
+ if __name__ == "__main__":
206
+ unittest.main(verbosity=2)