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
8 changes: 4 additions & 4 deletions .devcontainer/agent-versions.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
# Record installed agent versions in qualification receipts.
CODEX_VERSION=latest
# First Draft compatibility pins are independent of the agents' update policy.
FIRSTDRAFT_CLI_VERSION=0.6.0
# CLI 0.6.x uses First Draft API 0.6.x and defaults to local root output.
FIRSTDRAFT_CLI_VERSION=0.7.0
# CLI 0.7.x uses First Draft API 0.6.x and defaults to local root output.
FIRSTDRAFT_CLI_DEFAULT_API_URL=https://firstdraft.com
FIRSTDRAFT_STAGING_API_URL=https://staging.firstdraft.com
# Shared Claude/Codex authoring Skill; exact source of published plugin 0.6.0 (API 0.6).
FIRSTDRAFT_SKILLS_REVISION=a322d1f6e46ea69b2d38257d79d3a22071bb2e74
# Shared Claude/Codex authoring Skill; exact source for plugin 0.7.0 (API 0.6).
FIRSTDRAFT_SKILLS_REVISION=35f1553f19dd2ff688a409cda09f693f78d77965
FIRSTDRAFT_CLAUDE_SKILL_NAME=create-full-stack-app
# Codex namespaces the canonical source checkout with its root plugin manifest.
FIRSTDRAFT_CODEX_SKILL_NAME=firstdraft:create-full-stack-app
Expand Down
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,13 @@ name the Skill or translate the request into a command.
have reached the service, follow the Skill's mode-specific recovery to choose the next command; confirmation
alone is not a retry instruction.
- Drawing Board setup creates `.env` from `.env.example` without overwriting it. The user pastes the staging token
there once. Never read, print, edit, or commit `.env`; do not ask for `/plugin` configuration, Codespaces secrets,
shell exports, or a GitHub PAT. Use `bin/agent-doctor --installation-only` for installation diagnostics and the
full `bin/agent-doctor` to add validation of the shared wrapper and `.env` without printing the token. These are
pre-Compile diagnostics; after root adoption, use the generated README and the exact Rails error.
into its `FIRSTDRAFT_API_TOKEN` entry; the wrapper passes it to the CLI as `FIRSTDRAFT_STAGING_API_TOKEN`. If the
CLI or Skill names that staging variable, keep `.env`'s key unchanged. If authentication is rejected, have the
user replace its value with a fresh staging token. Never read, print, edit, or commit `.env`; do not ask for
`/plugin` configuration, Codespaces secrets, shell exports, or a GitHub PAT. Use
`bin/agent-doctor --installation-only` for installation diagnostics and the full `bin/agent-doctor` to validate the
shared wrapper and `.env` without printing the token. These are pre-Compile diagnostics; after root adoption,
use the generated README and the exact Rails error.

## Collaboration and credentials

Expand Down
7 changes: 6 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,12 @@ Never commit a First Draft API token, GitHub token, agent credential, or generat
repository for common credential shapes and verifies that `.env` remains ignored.

The shared ignored `.env` is the credential path for both agents; do not add agent-specific token configuration.
The template wrapper intentionally selects staging. Production defaults, GitHub Publication, and Service deployment
The template wrapper intentionally selects staging. Its existing `.env` format keeps the staging token under
`FIRSTDRAFT_API_TOKEN`; the wrapper maps it to the CLI's `FIRSTDRAFT_STAGING_API_TOKEN`, removes the production token
and legacy plugin settings from the child environment, and overrides any inherited staging token. This applies to
the version probe as well as the requested command. A blank `.env` token never falls back to shell credentials.
The standalone CLI defaults to production and selects staging with `--staging`; Drawing Board's wrapper continues
to select staging through its required URL. Production defaults, GitHub Publication, and Service deployment
are owned by [firstdraft/firstdraft](https://github.com/firstdraft/firstdraft); Skill and plugin delivery are owned by
[firstdraft/skills](https://github.com/firstdraft/skills).

Expand Down
2 changes: 1 addition & 1 deletion DIRECT_COMPILATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ hosted Compile-to-publication journey. Never run the nested initializer or appli
The optional `./application` and Publication paths remain available. The packets and dated receipts below preserve
the earlier nested-first delivery sequence; they are not instructions to initialize a nested app after root Compile.

## Current Skills source pin
## Package pins and prior release observations

The exact released CLI, Skills source, and runtime pins live in
[`.devcontainer/agent-versions.env`](.devcontainer/agent-versions.env). The September 22 local release selected
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ You will need:
- a Claude account with Claude Code access, or a ChatGPT account with Codex access.

Use the same personal GitHub account to create the Codespace and sign in to First Draft.
Drawing Board uses staging. The standalone CLI and Skill use production by default, so a token from
`firstdraft.com` will not work in this workspace; create its token on `staging.firstdraft.com` instead.

## 1. Create your Drawing Board

Expand Down Expand Up @@ -436,6 +438,10 @@ bin/agent-doctor
The doctor reports whether the token is present without showing it. If it reports an `.env` permissions problem,
run `chmod 600 .env` and try again.

If a present token is rejected, create a replacement at <https://staging.firstdraft.com/api-tokens> and replace the
value on `.env`'s `FIRSTDRAFT_API_TOKEN` line. Keep that key name even when a CLI message says
`FIRSTDRAFT_STAGING_API_TOKEN`; the Drawing Board wrapper translates it for you.

After root Compile, use the generated application's README and the exact Rails error instead of rerunning Drawing
Board setup or its doctor. The old tooling is under `.firstdraft/design/`, and the existing container's PATH still
reflects its pre-Compile layout. If a reconnect says the private-port refresh found an active listener, stop `bin/dev` before
Expand Down
26 changes: 14 additions & 12 deletions bin/firstdraft
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function readConfiguration(root = repositoryRoot) {
}

function requiresApiToken(arguments_) {
arguments_ = arguments_.filter((argument) => argument !== "--staging");
if (arguments_.some((argument) => argument === "--help" || argument === "-h") ||
arguments_.length === 0 || arguments_[0] === "--version") {
return false;
Expand Down Expand Up @@ -91,9 +92,21 @@ async function run({
throw new Error("the pinned standalone First Draft CLI is missing; rerun .devcontainer/setup-agents.");
}

const childEnvironment = {
...environment,
FIRSTDRAFT_STAGING_API_TOKEN: configuration.apiToken,
FIRSTDRAFT_API_URL: configuration.apiUrl,
};
delete childEnvironment.FIRSTDRAFT_API_TOKEN;
delete childEnvironment.FIRSTDRAFT_BASE_URL;
delete childEnvironment.CLAUDE_PLUGIN_OPTION_API_TOKEN;
delete childEnvironment.CLAUDE_PLUGIN_OPTION_API_URL;
delete childEnvironment.CLAUDE_PLUGIN_OPTION_api_token;
delete childEnvironment.CLAUDE_PLUGIN_OPTION_api_url;

const version = spawnSync(downstreamCli, ["--version"], {
encoding: "utf8",
env: environment,
env: childEnvironment,
});
const versionTokens = (version.stdout ?? "").trim().split(/\s+/);
if (version.status !== 0 || version.signal || !versionTokens.includes(expected.cliVersion)) {
Expand All @@ -106,17 +119,6 @@ async function run({
}
}

const childEnvironment = {
...environment,
FIRSTDRAFT_API_TOKEN: configuration.apiToken,
FIRSTDRAFT_API_URL: configuration.apiUrl,
};
delete childEnvironment.FIRSTDRAFT_BASE_URL;
delete childEnvironment.CLAUDE_PLUGIN_OPTION_API_TOKEN;
delete childEnvironment.CLAUDE_PLUGIN_OPTION_API_URL;
delete childEnvironment.CLAUDE_PLUGIN_OPTION_api_token;
delete childEnvironment.CLAUDE_PLUGIN_OPTION_api_url;

return await new Promise((resolve, reject) => {
const child = spawn(downstreamCli, arguments_, {
env: childEnvironment,
Expand Down
2 changes: 1 addition & 1 deletion script/check
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ if [[ ! "$FIRSTDRAFT_CLI_VERSION" =~ ^0\.[0-9]+\.[0-9]+$ ]]; then
fi

set +e
git grep --untracked -IEn '(fd_[A-Za-z0-9_-]{20,}|FIRSTDRAFT_API_TOKEN=.+|gh[opsu]_[A-Za-z0-9]{20,})' \
git grep --untracked -IEn '(fd_[A-Za-z0-9_-]{20,}|FIRSTDRAFT_(STAGING_)?API_TOKEN=.+|gh[opsu]_[A-Za-z0-9]{20,})' \
-- . ':(exclude)script/check'
credential_status=$?
set -e
Expand Down
82 changes: 66 additions & 16 deletions script/check-firstdraft-wrapper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ try {
const devcontainerDirectory = path.join(testRepository, ".devcontainer");
const fakeCli = path.join(temporaryRoot, "firstdraft");
const probeOutput = path.join(temporaryRoot, "probe.json");
const versionProbeOutput = path.join(temporaryRoot, "version-probe.json");
fs.mkdirSync(devcontainerDirectory, {recursive: true});
fs.copyFileSync(
path.join(repositoryRoot, ".devcontainer", "agent-versions.env"),
Expand All @@ -25,26 +26,30 @@ try {
fs.writeFileSync(fakeCli, `#!/usr/bin/env node
const fs = require("node:fs");
const arguments_ = process.argv.slice(2);
if (arguments_.length === 1 && arguments_[0] === "--version") {
process.stdout.write("firstdraft " +
(process.env.FIRSTDRAFT_TEST_CLI_VERSION ?? "0.6.0") + "\\n");
if (process.env.FIRSTDRAFT_TEST_CLI_NOTICE) {
process.stderr.write("A benign version notice.\\n");
}
process.exit(0);
}
fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({
const probe = {
apiUrl: process.env.FIRSTDRAFT_API_URL,
arguments_,
tokenIsExpected: process.env.FIRSTDRAFT_API_TOKEN === "test-token",
tokenPresent: Boolean(process.env.FIRSTDRAFT_API_TOKEN),
stagingTokenIsExpected: process.env.FIRSTDRAFT_STAGING_API_TOKEN === "test-token",
stagingTokenPresent: Boolean(process.env.FIRSTDRAFT_STAGING_API_TOKEN),
productionTokenPresent: Object.prototype.hasOwnProperty.call(process.env, "FIRSTDRAFT_API_TOKEN"),
legacyUrlPresent: Object.prototype.hasOwnProperty.call(process.env, "FIRSTDRAFT_BASE_URL"),
pluginOptionsPresent: [
"CLAUDE_PLUGIN_OPTION_API_TOKEN",
"CLAUDE_PLUGIN_OPTION_API_URL",
"CLAUDE_PLUGIN_OPTION_api_token",
"CLAUDE_PLUGIN_OPTION_api_url",
].some((key) => Object.prototype.hasOwnProperty.call(process.env, key)),
}));
};
if (arguments_.length === 1 && arguments_[0] === "--version") {
fs.writeFileSync(process.env.FIRSTDRAFT_TEST_VERSION_OUTPUT, JSON.stringify(probe));
process.stdout.write("firstdraft " +
(process.env.FIRSTDRAFT_TEST_CLI_VERSION ?? "0.7.0") + "\\n");
if (process.env.FIRSTDRAFT_TEST_CLI_NOTICE) {
process.stderr.write("A benign version notice.\\n");
}
process.exit(0);
}
fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify(probe));
`);
fs.chmodSync(fakeCli, 0o755);

Expand All @@ -63,14 +68,16 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({
};
const testEnvironment = {
...process.env,
FIRSTDRAFT_API_TOKEN: "ambient-token",
FIRSTDRAFT_API_TOKEN: "ambient-production-token",
FIRSTDRAFT_STAGING_API_TOKEN: "ambient-staging-token",
FIRSTDRAFT_API_URL: "https://wrong.example.com",
FIRSTDRAFT_BASE_URL: "https://legacy.example.com",
CLAUDE_PLUGIN_OPTION_API_TOKEN: "uppercase-token",
CLAUDE_PLUGIN_OPTION_API_URL: "https://uppercase.example.com",
CLAUDE_PLUGIN_OPTION_api_token: "lowercase-token",
CLAUDE_PLUGIN_OPTION_api_url: "https://lowercase.example.com",
FIRSTDRAFT_TEST_OUTPUT: probeOutput,
FIRSTDRAFT_TEST_VERSION_OUTPUT: versionProbeOutput,
};

assert.throws(
Expand Down Expand Up @@ -103,6 +110,10 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({
assert.equal(requiresApiToken(["generate", "uuid"]), false);
assert.equal(requiresApiToken(["future", "network-command"]), true);
assert.equal(requiresApiToken(["--version"]), false);
assert.equal(requiresApiToken(["--staging", "--version"]), false);
assert.equal(requiresApiToken(["--staging", "plan", "init", "--name", "Test"]), false);
assert.equal(requiresApiToken(["--staging", "generate", "uuid"]), false);
assert.equal(requiresApiToken(["--staging", "plan", "push"]), true);
await assert.rejects(
run({
arguments_: ["plan", "push"],
Expand All @@ -127,9 +138,48 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({
assert.deepEqual(JSON.parse(fs.readFileSync(probeOutput, "utf8")), {
apiUrl: "https://staging.firstdraft.com",
arguments_: ["plan", "push"],
legacyUrlPresent: false,
pluginOptionsPresent: false,
productionTokenPresent: false,
stagingTokenIsExpected: true,
stagingTokenPresent: true,
});
assert.deepEqual(JSON.parse(fs.readFileSync(versionProbeOutput, "utf8")), {
apiUrl: "https://staging.firstdraft.com",
arguments_: ["--version"],
legacyUrlPresent: false,
pluginOptionsPresent: false,
productionTokenPresent: false,
stagingTokenIsExpected: true,
stagingTokenPresent: true,
});

writeEnvironment();
const localResult = await run({
arguments_: ["--staging", "plan", "init", "--name", "Test"],
downstreamCli: fakeCli,
environment: testEnvironment,
root: testRepository,
stdio: "ignore",
});
assert.deepEqual(localResult, {signal: null, status: 0});
assert.deepEqual(JSON.parse(fs.readFileSync(probeOutput, "utf8")), {
apiUrl: "https://staging.firstdraft.com",
arguments_: ["--staging", "plan", "init", "--name", "Test"],
legacyUrlPresent: false,
pluginOptionsPresent: false,
productionTokenPresent: false,
stagingTokenIsExpected: false,
stagingTokenPresent: false,
});
assert.deepEqual(JSON.parse(fs.readFileSync(versionProbeOutput, "utf8")), {
apiUrl: "https://staging.firstdraft.com",
arguments_: ["--version"],
legacyUrlPresent: false,
pluginOptionsPresent: false,
tokenIsExpected: true,
tokenPresent: true,
productionTokenPresent: false,
stagingTokenIsExpected: false,
stagingTokenPresent: false,
});

const injectionMarker = path.join(temporaryRoot, "injected");
Expand Down Expand Up @@ -164,7 +214,7 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({
root: testRepository,
stdio: "ignore",
}),
/standalone First Draft CLI must be exactly 0\.6\.0/,
/standalone First Draft CLI must be exactly 0\.7\.0/,
);
} finally {
fs.rmSync(temporaryRoot, {force: true, recursive: true});
Expand Down
Loading