Skip to content
This repository was archived by the owner on Mar 30, 2026. It is now read-only.
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ volumeSize: 10
model: anthropic/claude-sonnet-4-5
codingAgent: claude-code
plugins:
- slack
- openclaw-linear
- test-slack
- test-linear
skills: []
templateVars:
- OWNER_NAME
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: test-linear
displayName: Test Linear
installable: true
needsFunnel: true
configPath: plugins.entries
secrets:
apiKey:
envVar: LINEAR_API_KEY
scope: agent
isSecret: true
required: true
autoResolvable: false
validator: "lin_api_"
webhookSecret:
envVar: LINEAR_WEBHOOK_SECRET
scope: agent
isSecret: true
required: true
autoResolvable: false
linearUserUuid:
envVar: LINEAR_USER_UUID
scope: agent
isSecret: false
required: false
autoResolvable: true
internalKeys:
- agentId
- linearUserUuid
configTransforms: []
webhookSetup:
urlPath: "/hooks/linear"
secretKey: webhookSecret
instructions:
- "1. Create webhook"
- "2. Paste URL"
configJsonPath: "plugins.entries.test-linear.config.webhookSecret"
hooks:
resolve:
linearUserUuid: 'echo "test-resolved-uuid-$(echo $LINEAR_API_KEY | cut -c1-8)"'
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: test-slack
displayName: Test Slack
installable: false
needsFunnel: false
configPath: channels
secrets:
botToken:
envVar: SLACK_BOT_TOKEN
scope: agent
isSecret: true
required: true
autoResolvable: false
validator: "xoxb-"
appToken:
envVar: SLACK_APP_TOKEN
scope: agent
isSecret: true
required: true
autoResolvable: false
validator: "xapp-"
internalKeys: []
configTransforms: []
12 changes: 7 additions & 5 deletions packages/cli/__e2e__/plugins.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe("Plugin Lifecycle: deploy → validate → destroy (Slack + Linear)", (
"PLUGINTESTER_SLACK_APP_TOKEN=xapp-fake-app-token-for-e2e",
"PLUGINTESTER_LINEAR_API_KEY=lin_api_fake_key_for_e2e",
"PLUGINTESTER_LINEAR_WEBHOOK_SECRET=fake-webhook-secret-for-e2e",
"PLUGINTESTER_LINEAR_USER_UUID=fake-uuid-for-e2e",
// LINEAR_USER_UUID intentionally omitted — resolved by test-linear hook
],
});

Expand Down Expand Up @@ -280,7 +280,9 @@ describe("Plugin Lifecycle: deploy → validate → destroy (Slack + Linear)", (
// Assert: Linear plugin secrets are in the cloud-init script
expect(cloudinitScript).toContain("LINEAR_API_KEY=");
expect(cloudinitScript).toContain("LINEAR_WEBHOOK_SECRET=");
// LINEAR_USER_UUID was resolved by the test-linear hook (echo-based stub)
expect(cloudinitScript).toContain("LINEAR_USER_UUID=");
expect(cloudinitScript).toContain("test-resolved-uuid-");
} finally {
dispose();
}
Expand Down Expand Up @@ -321,17 +323,17 @@ describe("Plugin Lifecycle: deploy → validate → destroy (Slack + Linear)", (
expect(containerCheck!.detail).toBe("running");

// Assert: Plugin secret checks were executed for Slack
const slackBotCheck = ui.getCheckResult("slack secret (SLACK_BOT_TOKEN)");
const slackBotCheck = ui.getCheckResult("test-slack secret (SLACK_BOT_TOKEN)");
expect(slackBotCheck).not.toBeNull();

const slackAppCheck = ui.getCheckResult("slack secret (SLACK_APP_TOKEN)");
const slackAppCheck = ui.getCheckResult("test-slack secret (SLACK_APP_TOKEN)");
expect(slackAppCheck).not.toBeNull();

// Assert: Plugin secret checks were executed for Linear
const linearApiCheck = ui.getCheckResult("openclaw-linear secret (LINEAR_API_KEY)");
const linearApiCheck = ui.getCheckResult("test-linear secret (LINEAR_API_KEY)");
expect(linearApiCheck).not.toBeNull();

const linearWebhookCheck = ui.getCheckResult("openclaw-linear secret (LINEAR_WEBHOOK_SECRET)");
const linearWebhookCheck = ui.getCheckResult("test-linear secret (LINEAR_WEBHOOK_SECRET)");
expect(linearWebhookCheck).not.toBeNull();

// Assert: Overall validation reports failures (expected with dummy secrets/API key)
Expand Down
1 change: 1 addition & 0 deletions packages/cli/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ program
.option("--env-file <path>", "Path to .env file (defaults to .env in project root)")
.option("--deploy", "Deploy immediately after setup")
.option("-y, --yes", "Skip confirmation prompt (for deploy)")
.option("--skip-hooks", "Skip plugin lifecycle hook execution")
.action(async (opts) => {
await setupCommand(opts);
});
Expand Down
111 changes: 47 additions & 64 deletions packages/cli/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
resolvePlugins,
PLUGIN_MANIFEST_REGISTRY,
} from "@clawup/core";
import { resolvePluginSecrets, runLifecycleHook } from "@clawup/core/manifest-hooks";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if runLifecycleHook is actually used in setup.ts
rg -n "runLifecycleHook" packages/cli/commands/setup.ts

# Also check file size to understand scope
wc -l packages/cli/commands/setup.ts

Repository: stepandel/clawup

Length of output: 183


Remove unused runLifecycleHook import from line 24.

The function is imported but never used in this file. Only resolvePluginSecrets from the same import is actually called (line 351).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/commands/setup.ts` at line 24, Remove the unused import
runLifecycleHook from the import statement that also brings in
resolvePluginSecrets (the import line containing resolvePluginSecrets and
runLifecycleHook); update the import to only import resolvePluginSecrets so the
symbol runLifecycleHook is no longer imported or referenced in
packages/cli/commands/setup.ts and ensure no other usages of runLifecycleHook
exist in the file.

import { fetchIdentity } from "@clawup/core/identity";
import { findProjectRoot } from "../lib/project";
import { selectOrCreateStack, setConfig, qualifiedStackName } from "../lib/pulumi";
Expand All @@ -40,6 +41,7 @@ interface SetupOptions {
envFile?: string;
deploy?: boolean;
yes?: boolean;
skipHooks?: boolean;
}

/** Fetched identity data stored alongside the agent definition */
Expand Down Expand Up @@ -292,18 +294,17 @@ export async function setupCommand(opts: SetupOptions = {}): Promise<void> {
p.log.success("All secrets resolved");

// -------------------------------------------------------------------------
// 7. Auto-resolve secrets (e.g., Linear UUID fetch)
// 7. Auto-resolve secrets (via manifest hooks or env overrides)
// -------------------------------------------------------------------------
// Generic: for each plugin with auto-resolvable secrets, attempt resolution
const autoResolvedSecrets: Record<string, Record<string, string>> = {};

for (const fi of fetchedIdentities) {
const plugins = agentPlugins.get(fi.agent.name);
if (!plugins) continue;

for (const pluginName of plugins) {
const manifest = resolvePlugin(pluginName, fi.identityResult);
for (const [key, secret] of Object.entries(manifest.secrets)) {
const pluginManifest = resolvePlugin(pluginName, fi.identityResult);
for (const [key, secret] of Object.entries(pluginManifest.secrets)) {
if (!secret.autoResolvable) continue;

const roleUpper = fi.agent.role.toUpperCase();
Expand All @@ -324,73 +325,55 @@ export async function setupCommand(opts: SetupOptions = {}): Promise<void> {
autoResolvedSecrets[fi.agent.role][key] = envValue;
continue;
}

// Plugin-specific auto-resolution logic
const resolved = await autoResolveSecret(pluginName, key, fi, resolvedSecrets, envDict);
if (resolved) {
if (!autoResolvedSecrets[fi.agent.role]) autoResolvedSecrets[fi.agent.role] = {};
autoResolvedSecrets[fi.agent.role][key] = resolved;
}
}
}
}

/**
* Auto-resolve a secret for a specific plugin.
* Currently supports Linear UUID fetch; future plugins can add their own resolvers here.
*/
async function autoResolveSecret(
pluginName: string,
key: string,
fi: FetchedIdentity,
secrets: ReturnType<typeof loadEnvSecrets>,
_envDict: Record<string, string>,
): Promise<string | undefined> {
if (pluginName === "openclaw-linear" && key === "linearUserUuid") {
const linearApiKey = secrets.perAgent[fi.agent.name]?.linearApiKey;
if (!linearApiKey) {
exitWithError(
`Cannot fetch Linear user UUID for ${fi.agent.displayName}: linearApiKey not resolved.`
);
}

const roleUpper = fi.agent.role.toUpperCase();
const s = p.spinner();
s.start(`Fetching Linear user ID for ${fi.agent.displayName}...`);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
const res = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: linearApiKey,
},
body: JSON.stringify({ query: "{ viewer { id } }" }),
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
// Use manifest resolve hooks (if not skipped and hooks exist)
if (!opts.skipHooks && pluginManifest.hooks?.resolve) {
// Build env for resolve hooks — include resolved secrets for this agent
const hookEnv: Record<string, string> = {};
const agentSecrets = resolvedSecrets.perAgent[fi.agent.name] ?? {};
for (const [k, v] of Object.entries(agentSecrets)) {
// Map camelCase key back to env var using plugin secret definitions
for (const [, sec] of Object.entries(pluginManifest.secrets)) {
const envDerivedKey = sec.envVar
.toLowerCase()
.split("_")
.map((part: string, i: number) => i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))
.join("");
if (envDerivedKey === k) {
hookEnv[sec.envVar] = v;
}
}
}
const data = (await res.json()) as { data?: { viewer?: { id?: string } }; errors?: Array<{ message: string }> };
if (data.errors && data.errors.length > 0) {
throw new Error(`GraphQL error: ${data.errors[0].message}`);

const s = p.spinner();
s.start(`Resolving secrets for ${fi.agent.displayName} (${pluginName})...`);
const hookResult = await resolvePluginSecrets({ manifest: pluginManifest, env: hookEnv });
if (hookResult.ok) {
// Map resolved env vars back to secret keys
for (const [secretKey, secret] of Object.entries(pluginManifest.secrets)) {
if (hookResult.values[secret.envVar]) {
// Skip if already resolved above
if (autoResolvedSecrets[fi.agent.role]?.[secretKey]) continue;
if (!autoResolvedSecrets[fi.agent.role]) autoResolvedSecrets[fi.agent.role] = {};
autoResolvedSecrets[fi.agent.role][secretKey] = hookResult.values[secret.envVar];
}
}
s.stop(`Resolved secrets for ${fi.agent.displayName} (${pluginName})`);
} else {
s.stop(`Failed to resolve secrets for ${fi.agent.displayName}`);
const roleUpper = fi.agent.role.toUpperCase();
exitWithError(
`${hookResult.error}\n` +
`Set the required env vars in your .env file (prefixed with ${roleUpper}_) to bypass hook resolution, then run \`clawup setup\` again.`
);
}
const uuid = data?.data?.viewer?.id;
if (!uuid) throw new Error("No user ID in response");
s.stop(`${fi.agent.displayName}: ${uuid}`);
return uuid;
} catch (err) {
s.stop(`Could not fetch Linear user ID for ${fi.agent.displayName}`);
exitWithError(
`Failed to fetch Linear user UUID: ${err instanceof Error ? err.message : String(err)}\n` +
`Set ${roleUpper}_LINEAR_USER_UUID in your .env file to bypass the API call, then run \`clawup setup\` again.`
);
}
}
}

return undefined;
if (opts.skipHooks) {
p.log.warn("Hooks skipped (--skip-hooks)");
}

// -------------------------------------------------------------------------
Expand Down
6 changes: 4 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"./dep-registry": "./dist/dep-registry.js",
"./plugin-registry": "./dist/plugin-registry.js",
"./coding-agent-registry": "./dist/coding-agent-registry.js",
"./schemas": "./dist/schemas/index.js"
"./schemas": "./dist/schemas/index.js",
"./manifest-hooks": "./dist/manifest-hooks.js"
},
"typesVersions": {
"*": {
Expand All @@ -27,7 +28,8 @@
"dep-registry": ["dist/dep-registry.d.ts"],
"plugin-registry": ["dist/plugin-registry.d.ts"],
"coding-agent-registry": ["dist/coding-agent-registry.d.ts"],
"schemas": ["dist/schemas/index.d.ts"]
"schemas": ["dist/schemas/index.d.ts"],
"manifest-hooks": ["dist/manifest-hooks.d.ts"]
}
},
"scripts": {
Expand Down
Loading