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
38 changes: 38 additions & 0 deletions .github/workflows/google-cloud-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Google Cloud integration tests
on:
push:
paths:
- 'plugin/skills/google-cloud/**'
- 'skills/google-cloud.md'
- 'skills.json'
- 'package.json'
- 'tests/google-cloud.test.mjs'
- '.github/workflows/google-cloud-tests.yml'
pull_request:
paths:
- 'plugin/skills/google-cloud/**'
- 'skills/google-cloud.md'
- 'skills.json'
- 'package.json'
- 'tests/google-cloud.test.mjs'
- '.github/workflows/google-cloud-tests.yml'
permissions:
contents: read
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.18.0'
- name: Check syntax and run dependency-free tests
run: |
node --check plugin/skills/google-cloud/scripts/gcloud.mjs
node --check plugin/skills/google-cloud/scripts/doctor.mjs
npm run test:gcloud
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,28 @@ macOS: `start.command` / `install-shortcut.command`.
| `$context-distillation` | Messy input → `.gitmir/brief.json` |
| `$legacy-maintenance` | Safe changes on coupled systems |
| `$stack-port` | Port old stack → new without losing behaviour |
| `$google-cloud` | SDK/context inspection, explicit GCP project binding, opt-in project-read verification and cloud task planning |

Flat copies under `skills/` are for the dashboard UI (copy-paste). Canonical Codex layout is `plugin/skills/<name>/SKILL.md`.

---

## Google Cloud integration

Google Cloud is an optional integration for projects managed by GitMir, **not a requirement to host GitMir in the cloud**. The existing dashboard skill catalog and the installable Codex plugin both expose `$google-cloud`.

From the GitMir checkout, inspect the local SDK and CLI context:

```bash
npm run gcloud:doctor -- --directory "/path/to/your/project"
```

The helper reads `.gitmir/google-cloud.json` in that project when present. Add `--verify-project` only to explicitly read the bound project's metadata through Google Cloud API. No deployment, billing changes, global project switching, token export or SDK update is performed. Local metadata is **not** proof of working cloud credentials or deployment permissions.

[Full setup, commands, limitations and verification](docs/google-cloud.md) · [Windows SDK / WinGet maintenance recipe](docs/windows/gcloud-winget-version-repair/README.md)

---

## Typical flow

```text
Expand Down
158 changes: 158 additions & 0 deletions docs/google-cloud.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
},
"scripts": {
"start": "node server.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"gcloud:doctor": "node plugin/skills/google-cloud/scripts/doctor.mjs",
"test:gcloud": "node --test tests/google-cloud.test.mjs"
},
"devDependencies": {
"@types/node": "^26.1.2",
Expand Down
64 changes: 64 additions & 0 deletions plugin/skills/google-cloud/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
name: google-cloud
description: Use when a GitMir-managed project uses Google Cloud, needs gcloud SDK or account diagnostics, has an ambiguous GCP project target, needs cloud task planning, or has Windows WinGet Unknown metadata for Google Cloud SDK.
---

# Google Cloud for GitMir

Connect a local project's GitMir model and verified task workflow to an explicit Google Cloud project. GitMir remains local; this skill does not deploy the dashboard to Google Cloud.

## Start with evidence

Identify the local project directory from the current task. Read its `AGENTS.md` and existing `.gitmir/model/` context when present. Do not infer a cloud target from a repository name, an account email, or an unrelated project in the CLI configuration.

Use the bundled `scripts/doctor.mjs` relative to this skill's directory. Resolve its absolute path and run Node with `--directory` pointing to the local project and `--json`. Quote paths as arguments in the current shell. The helper travels with the skill through GitMir's directory-link installers and plugin package.

For a dashboard copy of this prompt, locate the installed `~/.agents/skills/google-cloud/scripts/doctor.mjs`, or the same file under `plugin/skills/google-cloud/scripts/` in the user's GitMir checkout. Do not invent a path. If neither exists, report that the helper is missing; do not silently download and execute a remote script.

The normal inspection only invokes `gcloud version`, `gcloud config list` and `gcloud auth list` with bounded JSON output. It does not authenticate, switch CLI configuration, update the SDK, alter the registry, print tokens or create cloud resources.

## Bind the project explicitly

The binding is `.gitmir/google-cloud.json` **inside the managed project**, not necessarily inside the GitMir checkout:

```json
{
"projectId": "demo-project-123",
"region": "europe-west1"
}
```

These are example identifiers, not approved deployment targets. Use values already supplied by the operator or authoritative project configuration. The file only accepts `projectId` and optional `region`; never put passwords, service-account keys, refresh tokens, OAuth client secrets or ADC contents in it. Region is descriptive context, not a verified service location.

If the operator requests verification against Google Cloud, use the helper's `--verify-project` flag. It requires the binding and performs only `gcloud projects describe` against the bound project. A different global CLI project is reported, not silently changed. Without a requested cloud read, remain in local inspection mode.

## Interpret results without inflating confidence

- `local-only`: local commands completed. Cloud access, billing, API availability and deployment permissions remain unverified, even with exit code 0.
- `verified-project-read`: the bound project's metadata was read and its lifecycle was ACTIVE. This does not prove Cloud Run, IAM, Artifact Registry, Google Ads API or billing permissions.
- `blocked`: a check failed. Use the reported step and error code; do not invent a successful result or change identities to bypass the failure.

`gcloud` authentication and Application Default Credentials are distinct configurations. The helper deliberately reports ADC as `not-checked`. An empty `gcloud auth list` is not proof that ADC or a workload identity cannot work. Likewise, a listed account is not proof that its credentials remain valid.

Do not persist reports or account identities into a public repository automatically. The report excludes raw credentials, but project IDs and account names can still be sensitive.

## Feed the existing GitMir workflow

For cloud implementation work, pass the operator's goal, explicit project binding and actual diagnostic result to `$task-planner`. Separate local code/configuration changes from remote cloud changes. Every remote task must state the exact project/resource, required API/IAM permissions, cost exposure, rollback and `## Verify` commands before it can be executed.

Use `$task-runner` only within the approved scope. An inspection request never authorizes deployment, IAM changes, service enablement, billing changes, secret creation, resource deletion or SDK maintenance. Do not deploy automatically. Model updates must follow the existing model schema and preserve its stable IDs. Record verified resource references and checks, not credentials or invented readiness percentages.

## Windows SDK maintenance

WinGet's available version is not necessarily the installed version. `Unknown` is not a pin. Read the actual SDK version with `gcloud version --format=json` before editing `DisplayVersion`.

In the GitMir checkout, `docs/windows/gcloud-winget-version-repair/README.md` documents the separate, opt-in 584.0.0 maintenance recipe: copy bundled Python, temporarily set `CLOUDSDK_PYTHON`, update, verify the real version, then change the existing uninstall metadata. Its original machine's successful end-to-end run was not confirmed. Never trigger it from the diagnostic helper, downgrade a newer SDK to match that historical example, or stamp a version that is not installed.

The plugin-only package may not contain the repository's `docs/` tree; report that limitation rather than fabricating or executing a missing maintenance script.

## References

- [gcloud config list](https://docs.cloud.google.com/sdk/gcloud/reference/config/list)
- [gcloud auth list](https://docs.cloud.google.com/sdk/gcloud/reference/auth/list)
- [gcloud projects describe](https://docs.cloud.google.com/sdk/gcloud/reference/projects/describe)
- [gcloud CLI authentication and ADC](https://docs.cloud.google.com/sdk/docs/authenticate)
147 changes: 147 additions & 0 deletions plugin/skills/google-cloud/scripts/doctor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { COMMANDS, PROJECT_ID, projectCommand, runGcloud } from './gcloud.mjs';

const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const text = value => typeof value === 'string' && value.length <= 320 && !/[\x00-\x1f\x7f]/.test(value) ? value : null;
const invalid = () => Object.assign(new Error('Unexpected gcloud response schema.'), { code: 'INVALID_RESPONSE' });

function validateBinding(value) {
if (!isObject(value) || Object.keys(value).some(key => !['projectId', 'region'].includes(key))
|| typeof value.projectId !== 'string' || !PROJECT_ID.test(value.projectId)
|| (value.region !== undefined && (typeof value.region !== 'string' || !/^[a-z][a-z0-9-]{1,62}$/.test(value.region)))) {
throw new Error('Invalid .gitmir/google-cloud.json: only a valid projectId and optional region are accepted; no credentials.');
}
return value.region === undefined ? { projectId: value.projectId } : { projectId: value.projectId, region: value.region };
}

export async function readBinding(directory) {
const base = path.resolve(directory);
const directoryInfo = await fs.stat(base).catch(() => null);
if (!directoryInfo?.isDirectory()) throw new Error('Target directory does not exist or is not accessible.');
const filename = path.join(base, '.gitmir', 'google-cloud.json');
let handle;
try {
handle = await fs.open(filename, 'r');
} catch (error) {
if (error.code === 'ENOENT') return null;
throw new Error('Cannot read the project binding. Check file permissions.');
}
try {
const info = await handle.stat();
if (!info.isFile() || info.size > 16384) throw new Error('Invalid binding file.');
const content = await handle.readFile('utf8');
return validateBinding(JSON.parse(content.replace(/^\uFEFF/, '')));
} catch {
throw new Error('Invalid .gitmir/google-cloud.json: expected a small JSON object with projectId and optional region only.');
} finally {
await handle.close();
}
}

/** Local metadata and project-read access are different levels of evidence. */
export async function inspectGoogleCloud({ binding = null, verifyProject = false, run = runGcloud } = {}) {
if (binding !== null) binding = validateBinding(binding);
if (verifyProject && !binding) throw new Error('--verify-project requires an explicit .gitmir/google-cloud.json binding.');
const report = {
schemaVersion: 1,
checkedAt: new Date().toISOString(),
status: 'local-only',
sdkVersion: null,
binding,
cli: { account: null, projectId: null, impersonatedServiceAccount: null },
credentialedAccounts: 0,
configuredAccountListed: false,
adc: 'not-checked',
cloudAccessVerified: false,
projectState: null,
warnings: [],
errors: [],
};
if (!binding) report.warnings.push('NO_PROJECT_BINDING');
let step = 'version';
try {
const version = await run(COMMANDS.version);
if (!isObject(version) || typeof version['Google Cloud SDK'] !== 'string' || !/^\d+\.\d+\.\d+$/.test(version['Google Cloud SDK'])) throw invalid();
report.sdkVersion = version['Google Cloud SDK'];
step = 'config';
const config = await run(COMMANDS.config);
if (!isObject(config) || (config.core !== undefined && !isObject(config.core)) || (config.auth !== undefined && !isObject(config.auth))) throw invalid();
report.cli = {
account: text(config.core?.account),
projectId: text(config.core?.project),
impersonatedServiceAccount: text(config.auth?.impersonate_service_account),
};
if (binding && report.cli.projectId && binding.projectId !== report.cli.projectId) report.warnings.push('CLI_PROJECT_DIFFERS_FROM_BINDING');
if (report.cli.impersonatedServiceAccount) report.warnings.push('SERVICE_ACCOUNT_IMPERSONATION_CONFIGURED');
step = 'auth';
const accounts = await run(COMMANDS.auth);
if (!Array.isArray(accounts) || accounts.some(account => !isObject(account) || !text(account.account))) throw invalid();
report.credentialedAccounts = accounts.length;
report.configuredAccountListed = accounts.some(account => account.account === report.cli.account);
if (!accounts.length) report.warnings.push('NO_LOCAL_CREDENTIALS');
else if (!report.configuredAccountListed) report.warnings.push('CONFIGURED_ACCOUNT_NOT_LISTED');
if (verifyProject) {
step = 'project';
const project = await run(projectCommand(binding.projectId));
if (!isObject(project) || project.projectId !== binding.projectId || project.lifecycleState !== 'ACTIVE') throw invalid();
report.projectState = 'ACTIVE';
report.cloudAccessVerified = true;
report.status = 'verified-project-read';
}
} catch (error) {
const known = ['GCLOUD_NOT_FOUND', 'GCLOUD_TIMEOUT', 'GCLOUD_OUTPUT_LIMIT', 'GCLOUD_COMMAND_FAILED', 'INVALID_JSON', 'INVALID_RESPONSE'];
const code = error.code === 'ENOENT' ? 'GCLOUD_NOT_FOUND' : known.includes(error.code) ? error.code : 'GCLOUD_COMMAND_FAILED';
report.errors.push({ step, code });
report.status = 'blocked';
}
return report;
}

export function parseOptions(args) {
const options = { directory: process.cwd(), json: false, verifyProject: false, help: false };
for (let i = 0; i < args.length; i++) {
if (args[i] === '--json') options.json = true;
else if (args[i] === '--verify-project') options.verifyProject = true;
else if (args[i] === '--help' || args[i] === '-h') options.help = true;
else if (args[i] === '--directory') {
const value = args[++i];
if (!value || value.startsWith('--')) throw new Error('--directory requires a directory path.');
options.directory = value;
} else throw new Error('Unknown argument. Use --help for supported options.');
}
return options;
}

export async function main(args = process.argv.slice(2)) {
let options;
try {
options = parseOptions(args);
if (options.help) {
console.log('GitMir Google Cloud doctor\nUsage: node doctor.mjs [--directory PATH] [--json] [--verify-project]\nDefault: local SDK, config and credential metadata only. No cloud writes.\n--verify-project: explicitly read the bound project through Google Cloud API.\nExit codes: 0 = checks completed (local-only is NOT cloud readiness), 1 = check failed, 2 = invalid input.');
return 0;
}
const binding = await readBinding(options.directory);
const report = await inspectGoogleCloud({ binding, verifyProject: options.verifyProject });
if (options.json) console.log(JSON.stringify(report, null, 2));
else {
console.log(`GitMir Google Cloud: ${report.status}\nSDK: ${report.sdkVersion || 'unavailable'}\nAccount: ${report.cli.account || 'not configured'}\nBound project: ${report.binding?.projectId || 'not configured'}\nCLI default project: ${report.cli.projectId || 'not configured'}\nProject read verified: ${report.cloudAccessVerified}\nADC, billing and deployment permissions: not checked`);
for (const warning of report.warnings) console.log(`Warning: ${warning}`);
for (const error of report.errors) console.log(`Error: ${error.step}: ${error.code}`);
}
return report.status === 'blocked' ? 1 : 0;
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid input.';
if (options?.json || args.includes('--json')) console.log(JSON.stringify({ schemaVersion: 1, status: 'blocked', error: 'INVALID_INPUT', message }));
else console.error(message);
return 2;
}
}

// Installers link the whole skill directory; resolve that link before identifying the entrypoint.
const entrypoint = process.argv[1] ? await fs.realpath(process.argv[1]).catch(() => null) : null;
if (entrypoint && import.meta.url === pathToFileURL(entrypoint).href) {
process.exitCode = await main();
}
Loading
Loading