Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 0.2.0 - 2026-07-21

- Clarified GTM skill readiness checks, site-snippet prerequisite, and when to use raw upstream commands versus extending `gtm-agent`.
- Added validated `createTrigger.config` compilation and `createTag` trigger bindings through singular `firingTriggerId` or plural `firingTriggerIds`, while preserving dry-run and publish gates.

## 0.1.0 - 2026-05-24

- Added `gtm-agent` CLI with doctor, install, inventory, snapshot, diff, plan, apply, backup, raw passthrough, and guide commands.
Expand Down
44 changes: 35 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,29 +69,55 @@ gtm-agent guide

## Declarative Plan

Phase 1 creates the trigger only:

```yaml
accountId: "123"
containerId: "456"
workspaceId: "7"
actions:
- kind: enableBuiltInVariables
types: ["pageUrl", "clickText"]
- kind: createTrigger
name: "All Pages"
type: "pageview"
name: "CE - article_product_click"
type: "CUSTOM_EVENT"
config:
customEventFilter:
- type: EQUALS
parameter:
- {type: TEMPLATE, key: arg0, value: "{{_event}}"}
- {type: TEMPLATE, key: arg1, value: article_product_click}
```

`createTrigger.config` and `createTag.config` are JSON objects expressed as YAML. Resource `type` values must be strings. Config cannot redefine declarative `name`, `type`, or tag firing-trigger fields. Tags accept either one quoted decimal `firingTriggerId` or a non-empty list of unique quoted decimal `firingTriggerIds`; both compile to the upstream `--firing-trigger-id` flag. The validator rejects numeric YAML values, blanks, zero, comma-packed singular values, duplicate IDs, and use of trigger IDs on non-tag actions.

GTM assigns a trigger ID only after creation, so do not guess it or pretend a later action in the same plan can reference the earlier result. Use a two-phase, exact-name workflow:

1. Run `inventory` and confirm there is no exact-name trigger already present.
2. Dry-run and execute a trigger-only plan.
3. Run `inventory` again and copy the returned numeric `triggerId`.
4. Confirm there is no exact-name tag already present, then dry-run and execute the tag plan with that ID.

Phase 2 is a separate file created only after inventory returns the assigned ID (`"20"` is an example inventory result):

```yaml
accountId: "123"
containerId: "456"
workspaceId: "7"
actions:
- kind: createTag
name: "GA4 purchase"
name: "GA4 - article_product_click"
type: "gaawe"
firingTriggerIds: ["20"]
config:
parameter:
- type: template
key: eventName
value: purchase
- kind: createVersion
name: "agent release"
notes: "Created by gtm-agent"
value: article_product_click
```

The control plane deliberately does not perform live duplicate checks during an offline dry-run. Inventory and snapshots remain the explicit source of live-state evidence.

Independent plans can also use `enableBuiltInVariables`, `createVariable`, `createVersion`, and the separately gated `publishVersion` action.

Publish action:

```yaml
Expand Down
36 changes: 24 additions & 12 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,33 +38,46 @@ Build a strong agent-friendly Google Tag Manager control-plane CLI without reinv

Plans can be YAML or JSON:

Trigger phase:

```yaml
accountId: "123"
containerId: "456"
workspaceId: "7"
actions:
- kind: enableBuiltInVariables
types: ["pageUrl", "clickText"]
- kind: createTrigger
name: "All Pages"
type: "pageview"
name: "CE - article_product_click"
type: "CUSTOM_EVENT"
config:
customEventFilter:
- type: EQUALS
parameter:
- {type: TEMPLATE, key: arg0, value: "{{_event}}"}
- {type: TEMPLATE, key: arg1, value: article_product_click}
```

After execution and a fresh inventory read, the returned ID is used in a separate tag plan:

```yaml
accountId: "123"
containerId: "456"
workspaceId: "7"
actions:
- kind: createTag
name: "GA4 purchase"
name: "GA4 - article_product_click"
type: "gaawe"
firingTriggerIds: ["20"]
config:
parameter:
- type: template
key: eventName
value: purchase
- kind: createVersion
name: "agent release"
notes: "Created by gtm-agent"
- kind: publishVersion
versionId: "42"
value: article_product_click
```

The first release intentionally supports the highest-value safe workflow primitives and leaves deep endpoint-specific authoring to raw upstream passthrough.

Trigger configuration is encoded into the upstream `--config` JSON argument. Reserved declarative fields (`name`, `type`, and tag firing-trigger fields) cannot be overridden inside config. A tag can bind to one `firingTriggerId` or several `firingTriggerIds`; the plan compiler validates quoted positive-decimal IDs and emits the upstream comma-separated `--firing-trigger-id` form. Because GTM generates IDs at mutation time, references to a newly created trigger use two reviewed plans with an inventory read between them. Offline plan compilation intentionally does not claim live exact-name uniqueness.

## Verification

Required checks before claiming completion:
Expand All @@ -75,4 +88,3 @@ Required checks before claiming completion:
- `go build -o ./gtm-agent ./cmd/gtm-agent`
- Fake upstream E2E: install a temporary `gtm` script on `PATH`, run doctor, inventory, snapshot, diff, dry-run apply, guarded publish failure, allowed publish success, and raw passthrough.
- If real GTM credentials are already available safely, run read-only `auth status` / inventory smoke. Do not create or publish real GTM resources without an explicit safe target.

36 changes: 21 additions & 15 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type runtime struct {
asJSON bool
}

const Version = "0.1.0"
const Version = "0.2.0"

func NewRoot(options Options) *cobra.Command {
if options.Out == nil {
Expand Down Expand Up @@ -610,6 +610,12 @@ Recommended loop:
6. Snapshot after edits and diff snapshots.
7. Publish only with both gates: --allow-publish --confirm <container-id>

Trigger-to-tag workflow:
Create a new trigger in one reviewed plan, then inventory the workspace and
copy its exact numeric triggerId into a second tag plan. Check the inventory
for an exact-name match before creating either resource. This avoids duplicate
resources and avoids guessing an ID that GTM assigns only after creation.

Raw escape hatch:
gtm-agent raw -- <any upstream gtm command>

Expand All @@ -620,20 +626,20 @@ const planTemplate = `accountId: "123"
containerId: "456"
workspaceId: "7"
actions:
- kind: enableBuiltInVariables
types: ["pageUrl", "clickText"]
- kind: createTrigger
name: "All Pages"
type: "pageview"
- kind: createTag
name: "GA4 purchase"
type: "gaawe"
name: "CE - article_product_click"
type: "CUSTOM_EVENT"
config:
parameter:
- type: template
key: eventName
value: purchase
- kind: createVersion
name: "agent release"
notes: "Created by gtm-agent"
customEventFilter:
- type: EQUALS
parameter:
- {type: TEMPLATE, key: arg0, value: "{{_event}}"}
- {type: TEMPLATE, key: arg1, value: article_product_click}

# After executing this trigger-only plan, run inventory and copy the assigned ID.
# Then create a separate tag plan whose action contains, for example:
# - kind: createTag
# name: "GA4 - article_product_click"
# type: "gaawe"
# firingTriggerIds: ["20"] # Replace with the verified inventory value.
`
61 changes: 58 additions & 3 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"

"github.com/vecyang1/gtm-agent-cli/internal/cli"
planpkg "github.com/vecyang1/gtm-agent-cli/internal/plan"
"github.com/vecyang1/gtm-agent-cli/internal/runner"
)

Expand Down Expand Up @@ -106,7 +107,7 @@ actions:

func TestCLIVersion(t *testing.T) {
out := runCLI(t, runner.NewFake(nil), "--version")
if !strings.Contains(out, "gtm-agent version 0.1.0") {
if !strings.Contains(out, "gtm-agent version 0.2.0") {
t.Fatalf("unexpected version output: %s", out)
}
}
Expand Down Expand Up @@ -193,6 +194,53 @@ actions:
}
}

func TestCLIApplyKeepsConfiguredTriggerAndTagDryRunFirst(t *testing.T) {
triggerCommand := `gtm triggers create --name CE - article_product_click --type CUSTOM_EVENT --config {"customEventFilter":[{"parameter":[{"key":"arg0","type":"TEMPLATE","value":"{{_event}}"},{"key":"arg1","type":"TEMPLATE","value":"article_product_click"}],"type":"EQUALS"}]} --account-id 123 --container-id 456 --workspace-id 7 --output json`
tagCommand := "gtm tags create --name GA4 - article_product_click --type gaawe --firing-trigger-id 20 --account-id 123 --container-id 456 --workspace-id 7 --output json"
fake := runner.NewFake(map[string]runner.Result{
triggerCommand: {Stdout: `{"triggerId":"20","name":"CE - article_product_click"}` + "\n"},
tagCommand: {Stdout: `{"tagId":"30","name":"GA4 - article_product_click"}` + "\n"},
})
tmp := t.TempDir()
planPath := filepath.Join(tmp, "article-click.yaml")
if err := os.WriteFile(planPath, []byte(`accountId: "123"
containerId: "456"
workspaceId: "7"
actions:
- kind: createTrigger
name: "CE - article_product_click"
type: "CUSTOM_EVENT"
config:
customEventFilter:
- type: EQUALS
parameter:
- {type: TEMPLATE, key: arg0, value: "{{_event}}"}
- {type: TEMPLATE, key: arg1, value: article_product_click}
- kind: createTag
name: "GA4 - article_product_click"
type: "gaawe"
firingTriggerIds: ["20"]
`), 0o600); err != nil {
t.Fatalf("write plan: %v", err)
}

dryRun := runCLI(t, fake, "apply", planPath, "--json")
if !strings.Contains(dryRun, `"dryRun": true`) || !strings.Contains(dryRun, `--firing-trigger-id 20`) {
t.Fatalf("dry-run did not expose the trigger binding: %s", dryRun)
}
if len(fake.Calls) != 0 {
t.Fatalf("dry-run unexpectedly called upstream GTM: %v", fake.Calls)
}

executed := runCLI(t, fake, "apply", planPath, "--execute", "--json")
if !strings.Contains(executed, `"dryRun": false`) || !strings.Contains(executed, `"tagId": "30"`) {
t.Fatalf("execute output missing upstream result: %s", executed)
}
if len(fake.Calls) != 2 || fake.Calls[0] != triggerCommand || fake.Calls[1] != tagCommand {
t.Fatalf("unexpected execute calls: %v", fake.Calls)
}
}

func TestCLIPlanValidateAndTemplate(t *testing.T) {
tmp := t.TempDir()
planPath := filepath.Join(tmp, "plan.yaml")
Expand Down Expand Up @@ -220,8 +268,15 @@ actions:
if err != nil {
t.Fatalf("read template: %v", err)
}
if !strings.Contains(string(templateData), "enableBuiltInVariables") || !strings.Contains(string(templateData), "createTag") {
t.Fatalf("template missing expected action examples: %s", string(templateData))
parsedTemplate, err := planpkg.Parse(templateData)
if err != nil {
t.Fatalf("generated template should validate: %v", err)
}
if len(parsedTemplate.Actions) != 1 || parsedTemplate.Actions[0].Kind != "createTrigger" {
t.Fatalf("starter template must contain only the trigger phase, got %#v", parsedTemplate.Actions)
}
if !strings.Contains(string(templateData), "create a separate tag plan") || !strings.Contains(string(templateData), "firingTriggerIds") {
t.Fatalf("template missing second-phase guidance: %s", string(templateData))
}
}

Expand Down
Loading