| name | google-script-deploy |
| description | Deploy an HTML file as a Google Apps Script web app with a stable URL. Called by other skills (e.g. a dashboard-generating skill) with a sourceDir argument. Handles clasp setup, authentication, project creation, and updates. Config lives in sourceDir/clasp-projects.json — no global state. |
| user_invocable | true |
| args | Pick a mode based on user intent:
- setup <sourceDir> First-time setup for a new project. Installs clasp if needed,
authenticates, creates the GAS project, generates boilerplate,
pushes, and creates the initial deployment. Saves config to
<sourceDir>/clasp-projects.json.
- deploy <sourceDir> Update an existing deployment. Copies the latest HTML file,
pushes, and redeploys in-place. URL does not change.
- status <sourceDir> Check clasp installation, auth, and the project config at
<sourceDir>/clasp-projects.json.
sourceDir is always an absolute path to the directory that contains the HTML file
and will hold clasp-projects.json and the deploy/ staging subdirectory.
|
Google Script Deploy
Deploy HTML files as Google Apps Script web apps with a permanent, stable URL.
Model Selection
See .claude/skills/_shared/MODEL_SELECTION.md (in your workspace) for full policy.
- Default model: Haiku — clasp invocations, file copies, and config writes are mechanical against a stable CLI
- Promote to Sonnet when: diagnosing a failed deployment (RAPT/auth errors, the GAS parser quirk, clasp version drift) or walking the user through first-time setup
- Promote to Opus when: never
Each project is self-contained in its sourceDir:
{sourceDir}/
├── security-dashboard.html ← generated by the calling skill
├── clasp-projects.json ← deployment config (created by setup, read by deploy)
└── deploy/ ← staging dir (created by setup, updated by deploy)
├── Code.gs
├── appsscript.json
├── .clasp.json
└── security-dashboard.html ← copy of latest HTML, updated on each deploy
No global config files. Each sourceDir owns its own deployment state.
clasp-projects.json schema
{
"scriptId": "1BxyzABC...",
"deploymentId": "AKfycby...",
"entryFile": "security-dashboard.html",
"title": "Security Dashboard",
"url": "https://script.google.com/macros/s/.../exec"
}
MODE: status
-
Check clasp: clasp --version
- Missing → run
npm install -g @google/clasp directly (non-interactive, no user delegation).
If npm itself is missing, tell the user to install Node.js + npm first (Homebrew: brew install node).
If install fails with EACCES (permission denied on global install), retry with sudo npm install -g @google/clasp
and explain why sudo was needed (system-managed npm prefix).
-
Check auth: clasp show-authorized-user
- Not logged in → this IS interactive (opens browser OAuth) and cannot be automated.
Tell the user to run
! clasp login in the prompt, wait for the "Logged in!" confirmation,
then re-check with clasp show-authorized-user and print the authenticated email so the user can verify.
-
Check project config: read {sourceDir}/clasp-projects.json
- File missing → "No project configured in this directory. Run setup first."
- File present → show: scriptId, deploymentId, URL, entryFile.
MODE: setup
Heads-up — two one-time-per-Google-account requirements
Before starting setup, tell the user that two things require manual action on the user's
Google account, neither of which the skill can automate:
clasp login — interactive browser OAuth (run via ! clasp login in the prompt).
- Apps Script API toggle — must be set to ON at https://script.google.com/home/usersettings
before
clasp create-script will succeed. The skill cannot toggle this; the user must.
Both are one-time per Google account. After the first project, subsequent projects on the
same account skip both steps. Surface these upfront so the user knows what to expect.
Step 1 — Prerequisites
Run in parallel:
clasp --version
clasp show-authorized-user
node --version
Handle missing prerequisites in this order (skill auto-installs what it can; delegates only browser OAuth):
- node missing → tell user to install Node.js first (Homebrew:
brew install node).
Stop until installed; clasp depends on it.
- clasp missing → run
npm install -g @google/clasp directly. If it fails with EACCES,
retry with sudo npm install -g @google/clasp. After install, re-check clasp --version.
- clasp not authenticated → genuinely interactive (browser OAuth). Tell the user to run
! clasp login in the prompt, wait for "Logged in!" confirmation, then re-check with
clasp show-authorized-user. Print the authenticated email so the user can confirm it is the right
Google account.
- Apps Script API not enabled on the user's Google account →
clasp create-script will
fail with "User has not enabled the Apps Script API." Tell the user to visit
https://script.google.com/home/usersettings and toggle "Apps Script API" to ON. This is
a per-Google-account setting and cannot be automated. Wait for the user to confirm before
retrying clasp create-script.
Step 2 — Gather project details
Ask the user:
-
HTML file name in sourceDir — e.g. security-dashboard.html
-
Project title — shown in the Apps Script editor, e.g. "Security Dashboard"
-
Timezone — IANA name for the GAS manifest (e.g. Europe/London). Offer the system
timezone (readlink /etc/localtime or $TZ) as the default rather than asking cold.
-
Access level — who can view the deployed web app? (webapp.access in the manifest.)
Default to the most restricted level that meets the need and widen deliberately —
deployed dashboards often carry internal or sensitive data, so a public default is a
footgun. The four valid values:
DOMAIN — only users in the deployer's Google Workspace domain ← default
MYSELF — only the deploying user
ANYONE — any logged-in Google user
ANYONE_ANONYMOUS — any user, even not logged in (public — only for content genuinely
meant to be public; never for internal or sensitive dashboards)
DOMAIN requires the deployer to be on a Google Workspace domain; from a personal Google
account it doesn't apply, so use MYSELF (or ANYONE for a cross-org audience). The
options actually offered at deploy time may be narrower than this list — a Workspace admin
can disable public deployments, in which case only MYSELF and DOMAIN appear.
Step 3 — Create the deploy directory
mkdir -p {sourceDir}/deploy
Step 4 — Generate GAS boilerplate
Write {sourceDir}/deploy/Code.gs:
function doGet() {
return HtmlService.createHtmlOutputFromFile('{entryFile_without_extension}')
.setTitle('{title}')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
entryFile_without_extension = entryFile with .html stripped. Apps Script references
HTML files without the extension in createHtmlOutputFromFile().
Write {sourceDir}/deploy/appsscript.json:
{
"timeZone": "{timeZone}",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"access": "{access_level}",
"executeAs": "USER_DEPLOYING"
}
}
Step 5 — Copy the HTML file into the staging dir
cp {sourceDir}/{entryFile} {sourceDir}/deploy/{entryFile}
Step 6 — Create the Apps Script project
cd {sourceDir}/deploy
clasp create-script --title "{title}" --type standalone --rootDir .
(Clasp v3 — create is still accepted as an alias.) Use --type standalone. The webapp/API
distinction is configured in appsscript.json via the webapp block, not at create time
(--type webapp returns "Invalid container file type" in v3). This writes .clasp.json to
the deploy directory.
Important — clasp create-script overwrites local appsscript.json with the remote default
(timeZone: America/New_York, no webapp block). Re-write appsscript.json from step 4 AFTER
this step, so the correct timezone and access level are in place before push. Order matters:
Step 6 (create-script) → re-write Step 4 file → Step 7 (push). Extract the scriptId from it:
cat {sourceDir}/deploy/.clasp.json
Step 7 — Push
cd {sourceDir}/deploy
clasp push --force
--force skips the interactive confirmation prompt.
Step 8 — Create the initial deployment
cd {sourceDir}/deploy
clasp create-deployment --description "Initial deployment"
(Clasp v3 — deploy is still accepted as an alias.) Output includes the deployment ID
(starts with AKfycby...). Parse it from the output. Use --json for structured output
if line parsing proves brittle: clasp create-deployment --description "..." --json.
Construct the URL: https://script.google.com/macros/s/{deploymentId}/exec
Step 9 — Write clasp-projects.json
Write {sourceDir}/clasp-projects.json:
{
"scriptId": "{scriptId}",
"deploymentId": "{deploymentId}",
"entryFile": "{entryFile}",
"title": "{title}",
"url": "https://script.google.com/macros/s/{deploymentId}/exec"
}
Step 10 — Report
Setup complete
==============
Source dir: {sourceDir}
Entry file: {entryFile}
Script ID: {scriptId}
Deployment ID: {deploymentId}
URL (stable): https://script.google.com/macros/s/{deploymentId}/exec
The URL is permanent. Future deployments update it in place.
Config saved to {sourceDir}/clasp-projects.json.
MODE: deploy
Step 1 — Load config
Read {sourceDir}/clasp-projects.json.
If missing: "No project configured. Run setup first."
Extract: entryFile, deploymentId, url.
Step 2 — Copy latest HTML
cp {sourceDir}/{entryFile} {sourceDir}/deploy/{entryFile}
Always copy — this ensures the staging dir has the file built by the most recent run.
Step 3 — Push
cd {sourceDir}/deploy
clasp push --force
Step 4 — Redeploy in-place
cd {sourceDir}/deploy
clasp update-deployment {deploymentId} --description "$(date '+%Y-%m-%d')"
(Clasp v3 — redeploy is still accepted as an alias.) Updates the existing deployment.
The URL in clasp-projects.json remains valid — no config update needed.
Step 5 — Report
Deployed
========
URL: {url}
Date: {today}
Deployment updated in place. URL unchanged.
Notes for calling skills
-
Invoke with an absolute sourceDir so there is no ambiguity about where config and
the deploy directory are created. The calling skill should resolve the absolute path
once at the start of its run via git, then pass "$SOURCE_DIR" to this skill — never
hardcode /Users/.../... paths. Pattern:
SOURCE_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/path/to/your/reports/dir"
/google-script-deploy deploy "$SOURCE_DIR"
This makes the calling skill portable to any clone of the repo. Falls back to pwd
if not in a git repo.
-
What the skill auto-runs vs delegates to the user:
- Auto-runs:
npm install -g @google/clasp (non-interactive), clasp create, clasp push, clasp deploy.
- Delegates to user (interactive only):
clasp login — runs ! clasp login so the browser
OAuth flow output lands in the conversation. Do not attempt to background it.
-
deploy/ is not committed to git — add deploy/ to the sourceDir's .gitignore.
The clasp-projects.json (small, stable) can be committed if the team wants the
deployment config in version control.
-
entryFile references in Code.gs use the name without .html — if the file is
security-dashboard.html, the reference is 'security-dashboard'.
-
RAPT (reauth) expiry — any clasp command can fail mid-run. Google Workspace
enforces periodic re-authentication for sensitive scopes (Apps Script deployment
is one). When the RAPT expires, clasp commands fail with:
{"error":"invalid_grant","error_description":"reauth related error (invalid_rapt)",
"error_subtype":"invalid_rapt"}
This is NOT the same as a normal token refresh — the refresh token alone cannot
recover. The user must re-do the OAuth flow.
Recovery (any clasp command, any mode):
- Detect the error: look for
invalid_rapt, invalid_grant, or
reauth related error in the clasp command's stderr/stdout.
- Tell the user:
Your clasp auth has expired (RAPT). Run \! clasp login` in
the prompt to re-authenticate, then I'll retry.`
- Wait for the user to confirm they're logged in (they'll see
You are logged in as ...).
- Retry the SAME clasp command that failed — do not restart the whole skill.
RAPT lifetime is set by the user's Workspace admin (typically a few hours to a
day). Expect this to happen periodically; it's not a setup problem.
-
GAS parser bug — avoid }/${ inside template literals. Google Apps Script's
server-side parser misreads a JS template literal containing the sequence
}/${ (close-interp, forward slash, open-interp) as the start of a regex literal,
mangling the rest of the script. The browser then shows
Uncaught SyntaxError: Failed to execute 'write' on 'Document': Unexpected token '<'
inside Google's own mae_html_user_ar.js loader, with no useful stack trace into
your code. Local rendering works fine; only the GAS-served version breaks.
Trigger pattern (example URL builder):
const url = `https://github.com/${ORG}/${repo}/security/dependabot`;
Workaround: use string concatenation when building paths/URLs from multiple
variables.
const url = 'https://github.com/' + ORG + '/' + repo + '/security/dependabot';
Template literals with / outside ${...} boundaries (closing HTML tags like
</td>, plain text, etc.) are NOT affected — only the }/${ interpolation-slash-
interpolation sequence triggers the bug. When the dashboard fails to render on GAS
but works locally, check first for this pattern.