Skip to content

Commit b67059d

Browse files
committed
feat(sdk): add scoped managed resource creation
1 parent f2f5f03 commit b67059d

23 files changed

Lines changed: 801 additions & 84 deletions

.changeset/calm-otters-list.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@openagentpack/sdk": minor
33
---
44

5-
Expose Bailian Managed Agents operation-level reads, cursor pagination, session events, file downloads, deployment actions, provider capability metadata, address-scoped Agent planning/apply with create-only safety, and Agent display names independent from logical YAML keys.
5+
Expose Bailian Managed Agents operation-level reads, cursor pagination, session events, file downloads, deployment actions, provider capability metadata, generic scoped create-only planning/apply, local Skill source inspection, scoped Vault Credential creation, and display names independent from logical YAML keys.

packages/sdk/src/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,15 @@ export {
2323
writeProjectRuntime,
2424
} from "./internal/core/project-runtime.ts";
2525

26-
export type { ResolvedProjectConfig } from "./internal/types/config.ts";
26+
export type {
27+
CredentialDecl,
28+
DeploymentDecl,
29+
EnvironmentDecl,
30+
ProjectConfig,
31+
ResolvedProjectConfig,
32+
SkillDecl,
33+
VaultDecl,
34+
} from "./internal/types/config.ts";
2735
export type { LoadedProjectConfig } from "./internal/parser/index.ts";
2836
export {
2937
resolveProjectConfig,
@@ -46,6 +54,7 @@ export type {
4654
ResourcePlanScope,
4755
ResourceRefreshResult,
4856
ResourceRuntimeOptions,
57+
ResourceSyncMode,
4958
ResourceSyncRun,
5059
} from "./internal/core/resource-runtime.ts";
5160

@@ -142,8 +151,21 @@ export type {
142151
SkillVersionPage,
143152
VaultListOptions,
144153
VaultPage,
154+
VaultCredentialInfo,
145155
} from "./internal/types/managed-api.ts";
146156

157+
export {
158+
createVaultCredential,
159+
createVaultCredentialWithStateBackend,
160+
planVaultCredentialCreate,
161+
planVaultCredentialCreateWithStateBackend,
162+
} from "./internal/core/vault-credential-runtime.ts";
163+
export type {
164+
VaultCredentialCreateOptions,
165+
VaultCredentialCreatePlan,
166+
VaultCredentialCreateResult,
167+
} from "./internal/core/vault-credential-runtime.ts";
168+
147169
export type {
148170
DestroyDefaultMemoryStoreResult,
149171
DestroyResourceResult,
@@ -288,6 +310,8 @@ export { LocalFileStateBackend } from "./internal/state/local-file-state-backend
288310
export type { StateScope } from "./internal/state/backend.ts";
289311

290312
export { extractSkillZipFiles } from "./internal/utils/normalize-skill-zip.ts";
313+
export { inspectSkillSource } from "./internal/core/skill-source.ts";
314+
export type { SkillSourceInspection } from "./internal/core/skill-source.ts";
291315

292316
export type {
293317
RuntimeFeedbackEvent,

packages/sdk/src/internal/core/agent-runtime.ts

Lines changed: 5 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
planProjectContext,
3131
type ResourceExecutionResult,
3232
type ResourcePlanResult,
33-
replaceResourcePlan,
33+
type ResourceSyncMode,
3434
selectDestructive,
3535
} from "./resource-runtime.ts";
3636

@@ -61,7 +61,7 @@ export interface AgentResourcePlanOptions {
6161
mode?: AgentResourceSyncMode;
6262
}
6363

64-
export type AgentResourceSyncMode = "reconcile" | "create-only";
64+
export type AgentResourceSyncMode = ResourceSyncMode;
6565

6666
export interface AgentResourceSyncOptions extends AgentResourcePlanOptions {
6767
policy?: DestructivePolicy;
@@ -251,29 +251,15 @@ export async function planAgentResources(
251251
): Promise<AgentResourcePlan> {
252252
const agent = getAgent(ctx, agentId);
253253
const rootAddress = collectAgentAddresses(ctx.config, agent.agentName, agent.provider)[0]!;
254-
let planned = await planProjectContext(ctx, {
254+
const planned = await planProjectContext(ctx, {
255255
provider: agent.provider,
256256
scope: { roots: [rootAddress] },
257+
mode: options.mode,
257258
refresh: options.refresh,
258259
quiet: options.quiet ?? true,
259260
});
260261
const actions = planned.plan.actions;
261-
let diagnostics = planned.plan.diagnostics;
262-
if (options.mode === "create-only") {
263-
const blockedReason = getCreateOnlyBlockedReason(planned, rootAddress);
264-
if (blockedReason) {
265-
diagnostics = [
266-
...diagnostics,
267-
{
268-
severity: "error",
269-
code: "agent.create_only.blocked",
270-
message: blockedReason,
271-
resource: rootAddress,
272-
},
273-
];
274-
planned = replaceResourcePlan(planned, { ...planned.plan, diagnostics });
275-
}
276-
}
262+
const diagnostics = planned.plan.diagnostics;
277263
return {
278264
agentId,
279265
provider: agent.provider,
@@ -386,29 +372,6 @@ export async function syncAgentResourcesWithStateBackend(
386372
return writeProjectRuntime(input, (ctx) => syncAgentResources(ctx, agentId, options));
387373
}
388374

389-
function getCreateOnlyBlockedReason(planned: ResourcePlanResult, rootAddress: ResourceAddress): string | undefined {
390-
const refreshError = planned.refreshResult?.errors[0];
391-
if (refreshError) {
392-
return `Cannot verify Agent dependencies because refresh failed for ${addressKey(refreshError.resource.address)}: ${refreshError.error}`;
393-
}
394-
395-
const rootKey = addressKey(rootAddress);
396-
const rootAction = planned.plan.actions.find((action) => addressKey(action.address) === rootKey);
397-
if (!rootAction) return `Scoped plan did not contain the target Agent ${rootKey}.`;
398-
if (rootAction.action !== "create") {
399-
return `Target Agent ${rootKey} must be a new resource, but the scoped plan requires '${rootAction.action}'.`;
400-
}
401-
402-
const dependencyChanges = planned.plan.actions.filter(
403-
(action) => addressKey(action.address) !== rootKey && action.action !== "no-op",
404-
);
405-
if (dependencyChanges.length > 0) {
406-
const labels = dependencyChanges.map((action) => `${addressKey(action.address)} (${action.action})`).join(", ");
407-
return `Agent create requires every declared dependency to be up-to-date. Reconcile these resources first: ${labels}.`;
408-
}
409-
return undefined;
410-
}
411-
412375
export function toAgentSyncResults(execution: ResourceExecutionResult): AgentSyncResult[] {
413376
return execution.results.map((result) => ({
414377
action: result.action,

packages/sdk/src/internal/core/managed-api-runtime.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,21 +312,27 @@ const UNSUPPORTED_OAUTH: ManagedAgentOperationCapability = {
312312
};
313313

314314
const BAILIAN_OPERATIONS: Record<string, ManagedAgentOperationCapability> = {
315+
"agent.create": { ...API_KEY, reason: "YAML declaration plus scoped create-only apply." },
315316
"agent.list": API_KEY,
316317
"agent.get": API_KEY,
317318
"agent.search": CLIENT_SEARCH,
318319
"agent.versions": API_KEY,
320+
"environment.create": { ...API_KEY, reason: "YAML declaration plus scoped create-only apply." },
319321
"environment.list": API_KEY,
320322
"environment.get": API_KEY,
321323
"environment.search": CLIENT_SEARCH,
324+
"skill.create": { ...API_KEY, reason: "YAML declaration plus scoped create-only apply." },
322325
"skill.list": API_KEY,
323326
"skill.get": API_KEY,
324327
"skill.search": CLIENT_SEARCH,
325328
"skill.versions": API_KEY,
326329
"skill.download": API_KEY,
330+
"vault.create": { ...API_KEY, reason: "YAML declaration plus scoped create-only apply." },
331+
"vault.credential.create": { ...API_KEY, reason: "YAML declaration plus scoped Vault transaction." },
327332
"vault.list": API_KEY,
328333
"vault.get": API_KEY,
329334
"vault.search": CLIENT_SEARCH,
335+
"deployment.create": { ...API_KEY, reason: "YAML declaration plus scoped create-only apply." },
330336
"deployment.list": API_KEY,
331337
"deployment.get": API_KEY,
332338
"deployment.search": { ...API_KEY, reason: "Maps to the server-side keyword parameter." },

packages/sdk/src/internal/core/resource-runtime.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@ import { readProjectRuntime, writeProjectRuntime } from "./project-runtime.ts";
1717
export interface ResourceRuntimeOptions extends DestructiveDecisionOptions {
1818
provider?: string;
1919
scope?: ResourcePlanScope;
20+
mode?: ResourceSyncMode;
2021
refresh?: boolean;
2122
refreshOnly?: boolean;
2223
quiet?: boolean;
2324
onFeedback?: RuntimeFeedbackSink;
2425
}
2526

27+
export type ResourceSyncMode = "reconcile" | "create-only";
28+
2629
export interface ResourcePlanScope {
2730
roots: ResourceAddress[];
2831
includeDependencies?: boolean;
@@ -51,6 +54,7 @@ export interface ResourcePlanResult {
5154
targetProviders?: string[];
5255
destructiveActions: PlannedAction[];
5356
selectedAddresses?: ResourceAddress[];
57+
mode?: ResourceSyncMode;
5458
}
5559

5660
export type DestructivePolicy = "block" | "prompt" | "force";
@@ -81,6 +85,10 @@ export async function syncProjectResourcesWithStateBackend(
8185
if (options.refreshOnly) {
8286
return { planned };
8387
}
88+
if (options.mode === "create-only") {
89+
const errorDiagnostic = planned.plan.diagnostics.find((diagnostic) => diagnostic.severity === "error");
90+
if (errorDiagnostic) throw new UserError(errorDiagnostic.message);
91+
}
8492
return {
8593
planned,
8694
execution: await executePlannedProject(planned, {
@@ -173,6 +181,9 @@ export async function planProjectContext(
173181
ctx: ProjectRuntimeContext,
174182
options: ResourceRuntimeOptions = {},
175183
): Promise<ResourcePlanResult> {
184+
if (options.mode === "create-only" && !options.scope) {
185+
throw new UserError("Resource create-only mode requires an explicit resource scope.");
186+
}
176187
let targetProviders = resolveTargetProviders(options.provider);
177188
if (!targetProviders && options.scope) {
178189
targetProviders = [...new Set(options.scope.roots.map((root) => root.provider))];
@@ -190,11 +201,14 @@ export async function planProjectContext(
190201
})
191202
: undefined;
192203

193-
const plan = await buildPlan(ctx.config, ctx.state.getStateFile(), {
204+
let plan = await buildPlan(ctx.config, ctx.state.getStateFile(), {
194205
providers: targetProviders,
195206
configPath: ctx.configPath,
196207
resourceAddresses: selectedAddresses,
197208
});
209+
if (options.mode === "create-only" && options.scope) {
210+
plan = enforceCreateOnlyPlan(plan, options.scope, toResourceRefreshResult(refreshResult));
211+
}
198212

199213
return {
200214
executionContext: ctx,
@@ -203,6 +217,54 @@ export async function planProjectContext(
203217
targetProviders,
204218
destructiveActions: selectDestructive(plan.actions),
205219
selectedAddresses,
220+
mode: options.mode,
221+
};
222+
}
223+
224+
function enforceCreateOnlyPlan(
225+
plan: ExecutionPlan,
226+
scope: ResourcePlanScope,
227+
refreshResult: ResourceRefreshResult | undefined,
228+
): ExecutionPlan {
229+
const reasons: string[] = [];
230+
const rootKeys = new Set(scope.roots.map(addressKey));
231+
const refreshError = refreshResult?.errors[0];
232+
if (refreshError) {
233+
reasons.push(
234+
`Cannot verify scoped dependencies because refresh failed for ${addressKey(refreshError.resource.address)}: ${refreshError.error}`,
235+
);
236+
}
237+
238+
for (const root of scope.roots) {
239+
const rootKey = addressKey(root);
240+
const rootAction = plan.actions.find((action) => addressKey(action.address) === rootKey);
241+
if (!rootAction) {
242+
reasons.push(`Scoped plan did not contain target resource ${rootKey}.`);
243+
} else if (rootAction.action !== "create") {
244+
reasons.push(`Target resource ${rootKey} must be new, but the scoped plan requires '${rootAction.action}'.`);
245+
}
246+
}
247+
248+
const dependencyChanges = plan.actions.filter(
249+
(action) => !rootKeys.has(addressKey(action.address)) && action.action !== "no-op",
250+
);
251+
if (dependencyChanges.length > 0) {
252+
const labels = dependencyChanges.map((action) => `${addressKey(action.address)} (${action.action})`).join(", ");
253+
reasons.push(`Create-only requires every scoped dependency to be up-to-date. Reconcile first: ${labels}.`);
254+
}
255+
256+
if (reasons.length === 0) return plan;
257+
return {
258+
...plan,
259+
diagnostics: [
260+
...plan.diagnostics,
261+
{
262+
severity: "error",
263+
code: "resource.create_only.blocked",
264+
message: reasons.join(" "),
265+
resource: scope.roots[0],
266+
},
267+
],
206268
};
207269
}
208270

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { readFileSync, statSync } from "node:fs";
2+
import { basename, resolve } from "node:path";
3+
import { parse } from "yaml";
4+
import { UserError } from "../errors.ts";
5+
import type { SkillFile } from "../types/skill-file.ts";
6+
import { collectFiles } from "../utils/collect-files.ts";
7+
import { extractSkillZipFiles } from "../utils/normalize-skill-zip.ts";
8+
9+
export interface SkillSourceInspection {
10+
name: string;
11+
sourcePath: string;
12+
files: SkillFile[];
13+
}
14+
15+
/** Inspect and normalize a local Skill directory, zip, or single SKILL.md. */
16+
export async function inspectSkillSource(
17+
source: string,
18+
options: { basePath?: string } = {},
19+
): Promise<SkillSourceInspection> {
20+
if (/^https?:\/\//i.test(source)) {
21+
throw new UserError("Skill source inspection only accepts a local directory, zip, or SKILL.md file.");
22+
}
23+
const sourcePath = resolve(options.basePath ?? process.cwd(), source);
24+
const sourceStat = statSync(sourcePath, { throwIfNoEntry: false });
25+
if (!sourceStat) throw new UserError(`Skill source not found: ${source}`);
26+
27+
let files: SkillFile[];
28+
if (sourceStat.isDirectory()) {
29+
files = collectFiles(sourcePath, "");
30+
} else if (sourceStat.isFile() && sourcePath.toLowerCase().endsWith(".zip")) {
31+
files = await extractSkillZipFiles(readFileSync(sourcePath));
32+
} else if (sourceStat.isFile() && basename(sourcePath).toLowerCase() === "skill.md") {
33+
files = [{ relativePath: "SKILL.md", content: readFileSync(sourcePath) }];
34+
} else {
35+
throw new UserError("Skill source must be a directory, .zip archive, or SKILL.md file.");
36+
}
37+
38+
const normalizedFiles = normalizeSkillRoot(files);
39+
const manifest = normalizedFiles.find((file) => file.relativePath === "SKILL.md")!;
40+
const name = parseSkillManifestName(manifest.content);
41+
return { name, sourcePath, files: normalizedFiles };
42+
}
43+
44+
function normalizeSkillRoot(files: SkillFile[]): SkillFile[] {
45+
if (files.some((file) => file.relativePath === "SKILL.md")) return files;
46+
const manifests = files.filter((file) => file.relativePath.endsWith("/SKILL.md"));
47+
if (manifests.length === 0) throw new UserError("Skill source does not contain SKILL.md.");
48+
if (manifests.length > 1) {
49+
throw new UserError("Skill source contains multiple SKILL.md files and has no unambiguous root.");
50+
}
51+
const prefix = manifests[0]!.relativePath.slice(0, -"SKILL.md".length);
52+
return files
53+
.filter((file) => file.relativePath.startsWith(prefix))
54+
.map((file) => ({ ...file, relativePath: file.relativePath.slice(prefix.length) }));
55+
}
56+
57+
function parseSkillManifestName(content: Buffer): string {
58+
const text = content.toString("utf8");
59+
const frontmatter = text.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
60+
if (!frontmatter) throw new UserError("SKILL.md must start with YAML frontmatter containing a name.");
61+
let manifest: unknown;
62+
try {
63+
manifest = parse(frontmatter[1]!);
64+
} catch (error) {
65+
throw new UserError(`Invalid SKILL.md frontmatter: ${error instanceof Error ? error.message : String(error)}`);
66+
}
67+
const rawName = manifest && typeof manifest === "object" ? (manifest as Record<string, unknown>).name : undefined;
68+
const name = typeof rawName === "string" ? rawName.trim() : "";
69+
if (!name) throw new UserError("SKILL.md frontmatter must contain a non-empty name.");
70+
return name;
71+
}

0 commit comments

Comments
 (0)