/** * Update Skill Command Handler - Point edit of a skill directly on the platform * * Usage: * newo update-skill --project --agent --flow \ * [--model /] \ * [--script ] \ * [--publish] [--publish-description ""] * * Unlike `newo push`, this changes exactly one skill without requiring a * pulled workspace and without touching any other modified files. Typical * use: temporarily switching a skill's model for an A/B test run. */ import fs from 'fs-extra'; import { requireSingleCustomer } from '../customer-selection.js'; import { makeClient, updateSkill, publishFlow } from '../../api.js'; import { getValidAccessToken } from '../../auth.js'; import { resolveRemoteSkill, parseModelFlag } from '../../sync/remote-skill.js'; import { projectDir } from '../../fsutil.js'; import { v2ProjectDir } from '../../format/paths-v2.js'; import { ENV } from '../../env.js'; import { EVIDENCE_SCHEMA_VERSION, createRunId, resolveManifestAccount, sha256Bytes, writeJsonFile, type EvidenceEnvelope } from '../../evidence/contract.js'; import type { MultiCustomerConfig, CliArgs, Skill, PublishFlowRequest, PublishFlowResponse } from '../../types.js'; const USAGE = 'Usage: newo update-skill --project --agent --flow [--model /] [--script ] [--publish] [--publish-description ""] [--manifest ] [--customer ]'; interface SkillDeploymentDetails extends Record { target: { project: { id: string; idn: string }; agent: { id: string; idn: string }; flow: { id: string; idn: string }; skill: { id: string; idn: string }; }; intended: { content_sha256: string; model: { provider_idn: string; model_idn: string }; }; update_acknowledged: boolean; publish: { requested: boolean; acknowledgement: PublishFlowResponse | null; }; static_live_readback: { performed: boolean; content_sha256: string | null; matches_intended: boolean | null; model_matches_intended: boolean | null; }; runtime_activation: { supported: false; revision: null; }; } export async function findLocalProjectWorkspace(customerIdn: string, projectIdn: string): Promise { const candidates = [ projectDir(customerIdn, projectIdn), v2ProjectDir(customerIdn, projectIdn) ]; for (const candidate of candidates) { if (await fs.pathExists(candidate)) { return candidate; } } return null; } export async function handleUpdateSkillCommand( customerConfig: MultiCustomerConfig, args: CliArgs, verbose: boolean = false ): Promise { const skillIdn = args._[1] as string | undefined; const projectIdn = args.project as string | undefined; const agentIdn = args.agent as string | undefined; const flowIdn = args.flow as string | undefined; const modelFlag = args.model as string | undefined; const scriptFlag = args.script as string | undefined; const shouldPublish = Boolean(args.publish); const publishDescription = args['publish-description'] as string | undefined; const manifestPath = args.manifest ? String(args.manifest) : null; const startedAt = new Date().toISOString(); if (!skillIdn || !projectIdn || !agentIdn || !flowIdn) { console.error('Error: skill IDN, --project, --agent and --flow are required'); console.error(USAGE); process.exit(1); } if (!modelFlag && !scriptFlag) { console.error('Error: nothing to update — pass --model and/or --script'); console.error(USAGE); process.exit(1); } const newModel = modelFlag ? parseModelFlag(String(modelFlag)) : null; let newScript: string | null = null; if (scriptFlag) { const scriptPath = String(scriptFlag); if (!(await fs.pathExists(scriptPath))) { console.error(`Error: script file not found: ${scriptPath}`); process.exit(1); } newScript = await fs.readFile(scriptPath, 'utf8'); } const selectedCustomer = requireSingleCustomer(customerConfig, args.customer as string | undefined); const token = await getValidAccessToken(selectedCustomer); const client = await makeClient(verbose, token); if (verbose) console.log(`🔍 Resolving skill ${projectIdn}/${agentIdn}/${flowIdn}/${skillIdn}...`); const { project, agent, flow, skill } = await resolveRemoteSkill(client, { projectIdn, agentIdn, flowIdn, skillIdn }); // Build updated skill object, preserving everything we don't change const updatedSkill: Skill = { ...skill, ...(newModel ? { model: newModel } : {}), ...(newScript !== null ? { prompt_script: newScript } : {}) }; console.log(`✏️ Updating skill: ${project.idn}/${agent.idn}/${flow.idn}/${skill.idn} (${skill.id})`); if (newModel) { console.log(` Model: ${skill.model.provider_idn}/${skill.model.model_idn} → ${newModel.provider_idn}/${newModel.model_idn}`); } if (newScript !== null) { console.log(` Script: ${(skill.prompt_script || '').length} chars → ${newScript.length} chars (from ${scriptFlag})`); } let updateAcknowledged = false; let publishAcknowledgement: PublishFlowResponse | null = null; let readbackContentSha: string | null = null; let readbackContentMatches: boolean | null = null; let readbackModelMatches: boolean | null = null; let operationError: unknown = null; let manifestSuccess: boolean | null = null; try { await updateSkill(client, updatedSkill); updateAcknowledged = true; console.log('✅ Skill updated (draft)'); // Warn when a pulled local workspace exists: it now diverges from the platform const localProjectDir = await findLocalProjectWorkspace(selectedCustomer.idn, project.idn); if (localProjectDir) { console.warn(`⚠️ Local workspace exists at ${localProjectDir} and now differs from the platform.`); console.warn(` Run 'newo pull' to sync it, or remember to revert this change.`); } if (shouldPublish) { const publishData: PublishFlowRequest = { version: '1.0', description: publishDescription || 'Published via NEWO CLI (update-skill)', type: 'public' }; publishAcknowledgement = await publishFlow(client, flow.id, publishData); console.log(`🚀 Flow published: ${flow.idn}`); } else { console.log(`💡 Changes are draft-only. Add --publish to publish flow '${flow.idn}'.`); } const readback = await resolveRemoteSkill(client, { projectIdn, agentIdn, flowIdn, skillIdn }); readbackContentSha = sha256Bytes(readback.skill.prompt_script || ''); readbackContentMatches = readbackContentSha === sha256Bytes(updatedSkill.prompt_script || ''); readbackModelMatches = readback.skill.model.provider_idn === updatedSkill.model.provider_idn && readback.skill.model.model_idn === updatedSkill.model.model_idn; } catch (error: unknown) { operationError = error; const apiError = error as { response?: { data?: Record }; message?: string }; const message = typeof apiError.response?.data?.['message'] === 'string' ? apiError.response.data['message'] : apiError.message || 'Unknown error'; console.error(`❌ Failed to update or publish '${flow.idn}/${skill.idn}': ${message}`); const errorDetails = apiError.response?.data?.['reasons'] || apiError.response?.data?.['errors'] || apiError.response?.data?.['detail']; if (errorDetails) console.error(` Details: ${JSON.stringify(errorDetails)}`); } if (manifestPath) { const accountIdentity = await resolveManifestAccount(client, selectedCustomer.idn); const intendedContentSha = sha256Bytes(updatedSkill.prompt_script || ''); const success = operationError === null && updateAcknowledged && readbackContentMatches === true && readbackModelMatches === true && (!shouldPublish || publishAcknowledgement?.success === true); manifestSuccess = success; const limitations = [ ...accountIdentity.limitations, 'The platform publish response exposes acknowledgement only; it does not expose an executed runtime revision or activation hash.', 'Static live readback verifies the designer skill object, not worker cold-start completion.', 'The command does not create a rollback snapshot; the caller owns restoration from its separately retained baseline.' ]; const envelope: EvidenceEnvelope = { schema_version: EVIDENCE_SCHEMA_VERSION, result_kind: 'skill_deployment', run_id: createRunId(), account: accountIdentity.account, environment: { base_url: ENV.NEWO_BASE_URL }, phase: shouldPublish ? 'publish' : 'update_draft', state: success ? 'SUCCEEDED' : 'FAILED', fault_owner: success ? null : 'platform', retryable: success ? null : operationError !== null, ...(!success ? { fault_message: operationError instanceof Error ? operationError.message : 'Acknowledgement or static live readback did not match the intended skill.' } : {}), evidence: [{ kind: 'skill_static_readback', ...(readbackContentSha ? { sha256: readbackContentSha } : {}), identifiers: { project_idn: project.idn, flow_idn: flow.idn, skill_idn: skill.idn } }], side_effects: [ { kind: 'skill_update', locator: { skill_id: skill.id, skill_idn: skill.idn }, state: updateAcknowledged ? 'updated' : 'unknown', cleanup: { owner: 'caller', supported: true, state: 'pending' } }, ...(shouldPublish ? [{ kind: 'flow_publish', locator: { flow_id: flow.id, flow_idn: flow.idn }, state: publishAcknowledgement?.success ? 'updated' as const : 'unknown' as const, cleanup: { owner: 'unsupported' as const, supported: false, state: 'unsupported' as const } }] : []) ], cleanup_owner: shouldPublish ? 'unsupported' : 'caller', timestamps: { started_at: startedAt, completed_at: new Date().toISOString() }, limitations, details: { target: { project: { id: project.id, idn: project.idn }, agent: { id: agent.id, idn: agent.idn }, flow: { id: flow.id, idn: flow.idn }, skill: { id: skill.id, idn: skill.idn } }, intended: { content_sha256: intendedContentSha, model: updatedSkill.model }, update_acknowledged: updateAcknowledged, publish: { requested: shouldPublish, acknowledgement: publishAcknowledgement }, static_live_readback: { performed: readbackContentSha !== null, content_sha256: readbackContentSha, matches_intended: readbackContentMatches, model_matches_intended: readbackModelMatches }, runtime_activation: { supported: false, revision: null } } }; await writeJsonFile(manifestPath, envelope); } if (operationError !== null || manifestSuccess === false) process.exitCode = 1; }