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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "seo",
"displayName": "seo",
"description": "Local-first SEO and AI-search diagnostics. Bundles the seo MCP server plus one SEO skill that gives an agent 50+ audit and report tools without filling its context window.",
"version": "0.2.35",
"version": "0.2.36",
"author": {
"name": "Ian Nuttall"
},
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,13 +329,18 @@ for setup, costs, caching, country-level limits, competitor classification, and
programmatic data-source checks.

Traffic analytics commands sit under their provider namespace. Google Analytics
uses the connected Google account:
uses the account saved with the project, or the selected default Google account:

```sh
seo analytics google properties
seo analytics google report --property 123456789 --dimensions landingPage --metrics sessions,totalUsers
```

You can keep several Google logins on one machine. Run `seo auth login` again
to add an account, then use `seo auth accounts` to list them. During `seo start`
you can select separate accounts for Search Console and Google Analytics.
Project reports use those saved choices automatically.

Clicky is available through the first-party
[Clicky provider package](https://github.com/iannuttall/seoskill-clicky-provider).
It supplies landing-page visits and Clicky analytics reports. Its README owns
Expand Down Expand Up @@ -849,7 +854,7 @@ provider caches are also local. Use these commands to inspect or remove them:
```sh
seo privacy
seo doctor
seo auth logout
seo auth logout --all
seo reset
```

Expand Down
39 changes: 35 additions & 4 deletions apps/web/src/content/docs/docs/google.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,37 @@ seo sites
seo projects list
```

## Connect more than one Google account

Run the login command again to add another account. Earlier logins stay saved
on this machine.

```sh
seo auth login
seo auth accounts
```

During `seo start`, choose one account for Search Console and another account
for Google Analytics if the site needs that split. The project profile keeps
both choices. Reports that use the project then select the correct login for
each data source.

Agents and CI can make the same choice with explicit flags:

```sh
seo start \
--site sc-domain:example.com \
--search-console-account <search-account-email> \
--google-analytics-property 123456789 \
--google-analytics-account <analytics-account-email> \
--json
```

Use `seo auth use <email>` to change the default login for commands that do not
use a saved project. Direct property commands also accept `--account <email>`.
Remove one login with `seo auth logout --account <email>`, or remove every
login with `seo auth logout --all`.

## Use Search Console to find search demand and affected pages

Search Console reports clicks, impressions, CTR, and average position for the
Expand Down Expand Up @@ -325,8 +356,8 @@ seo auth whoami
config directory. Do not commit it to the repository. Environment-based setups
can provide `SEO_GOOGLE_CLIENT_ID` and `SEO_GOOGLE_CLIENT_SECRET` instead.

The CLI records which client created a saved login. If you switch from the
shared client to your own client, run `seo auth logout` before signing in again.
The CLI records which client created each saved login. If you switch from the
shared client to your own client, run `seo auth logout --all` before signing in again.
`seo auth status` shows whether the active login uses the shared SEO Skill app
or your own client.

Expand Down Expand Up @@ -435,11 +466,11 @@ credential configuration before a report runs.
## Remove access and local tokens

```sh
seo auth logout
seo auth logout --all
seo privacy
```

Logout deletes locally stored Google tokens. It does not delete your Search
This command deletes all locally stored Google tokens. It does not delete your Search
Console property or Google Analytics property. The [privacy policy](/privacy) documents the
local paths and network requests, while the [setup guide](/docs/getting-started)
covers project profiles and first-run troubleshooting.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "seo",
"version": "0.2.35",
"version": "0.2.36",
"description": "The SEO command for AI agents. Audit sites and research search opportunities with local, evidence-backed reports.",
"type": "module",
"license": "Apache-2.0",
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/commands/analytics/google/properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ export const googleAnalyticsPropertiesCommand = defineCommand({
description:
'Save this numeric Google Analytics property ID as the default.',
},
account: {
type: 'string',
description: 'Saved Google account email.',
},
},
run: async ({ args }) => {
const summaries = await listGa4AccountSummaries()
const summaries = await listGa4AccountSummaries(stringArg(args.account))
const rows = summaries.flatMap((account) =>
account.propertySummaries.map((property) => ({
account: account.displayName ?? account.account,
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/commands/analytics/google/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function resolveGoogleAnalyticsReportProperty(
input: {
property?: string
project?: string
options?: { json?: boolean }
options?: { json?: boolean; account?: string }
},
dependencies: GoogleAnalyticsPropertySelectionDependencies = {
resolveClient,
Expand Down Expand Up @@ -72,6 +72,10 @@ export const googleAnalyticsReportCommand = defineCommand({
description:
'Saved project id or name with an optional Google Analytics property.',
},
account: {
type: 'string',
description: 'Saved Google account email.',
},
'start-date': { type: 'string', default: '28daysAgo' },
'end-date': { type: 'string', default: 'yesterday' },
dimensions: { type: 'string', default: 'landingPage' },
Expand All @@ -91,7 +95,7 @@ export const googleAnalyticsReportCommand = defineCommand({
const property = await resolveGoogleAnalyticsReportProperty({
property: stringArg(args.property),
project: projectArg(args),
options: { json },
options: { json, account: stringArg(args.account) },
})
const body =
(await jsonBodyArg(args.body, args['body-file'])) ??
Expand All @@ -108,6 +112,7 @@ export const googleAnalyticsReportCommand = defineCommand({
} as Record<string, unknown>)
const result = await runGa4Report(property, body as never, {
refresh: booleanArg(args.refresh),
accountEmail: stringArg(args.account),
})
if (json) {
printJson(result)
Expand Down
71 changes: 71 additions & 0 deletions packages/cli/src/commands/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,74 @@ test('auth whoami prints a readable account summary', async () => {
await rm(cacheDir, { recursive: true, force: true })
}
})

test('auth lists and selects saved Google accounts', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'seo-accounts-config-'))
const cacheDir = await mkdtemp(join(tmpdir(), 'seo-accounts-cache-'))
const env = { SEO_CONFIG_DIR: configDir, SEO_CACHE_DIR: cacheDir }
const token = (accountEmail: string) => ({
provider: 'google',
account_email: accountEmail,
scope: 'openid email',
token_type: 'Bearer',
access_token: `${accountEmail}-access`,
refresh_token: `${accountEmail}-refresh`,
expires_at: Date.now() + 3_600_000,
obtained_at: Date.now(),
client_source: 'shared',
})

try {
await writeFile(
join(configDir, 'config.json'),
JSON.stringify({ security: { useKeychain: false } }),
)
await writeFile(
join(configDir, 'tokens.json'),
JSON.stringify({
version: 2,
active_account: 'first@example.com',
accounts: [token('first@example.com'), token('second@example.com')],
}),
)

const listed = await runSeo(['auth', 'accounts', '--json'], env)
assert.equal(listed.exitCode, 0)
assert.deepEqual(
JSON.parse(listed.stdout).accounts.map(
(account: { accountEmail: string; active: boolean }) => ({
email: account.accountEmail,
active: account.active,
}),
),
[
{ email: 'first@example.com', active: true },
{ email: 'second@example.com', active: false },
],
)

const selected = await runSeo(['auth', 'use', 'second@example.com'], env)
assert.equal(selected.exitCode, 0)

const whoami = await runSeo(['auth', 'whoami', '--json'], env)
assert.equal(JSON.parse(whoami.stdout).account, 'second@example.com')

const loggedOut = await runSeo(['auth', 'logout'], env)
assert.equal(loggedOut.exitCode, 0)
assert.match(loggedOut.stdout, /second@example\.com/)

const remaining = await runSeo(['auth', 'accounts', '--json'], env)
assert.deepEqual(
JSON.parse(remaining.stdout).accounts.map(
(account: { accountEmail: string; active: boolean }) => ({
email: account.accountEmail,
active: account.active,
}),
),
[{ email: 'first@example.com', active: true }],
)
} finally {
await rm(configDir, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
}
})
Loading