agentlinkops 0.6.9 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +44 -12
- package/cli/admission.js +9 -1
- package/cli/agent-setup.js +189 -20
- package/cli/catalog.js +7938 -1357
- package/cli/client.js +89 -23
- package/cli/cloud-error.js +21 -0
- package/cli/commands.js +15 -3
- package/cli/connection.js +74 -12
- package/cli/doctor.js +24 -9
- package/cli/gsc-browser.js +19 -6
- package/cli/ledger.js +4 -7
- package/cli/lifecycle.js +5 -2
- package/cli/local-result.js +4 -4
- package/cli/main.js +42 -27
- package/cli/mcp-client.js +198 -0
- package/cli/native-lock.js +22 -0
- package/cli/oauth-credentials.js +196 -0
- package/cli/oauth-flow.js +259 -0
- package/cli/setup-plan.js +5 -5
- package/cli/status.js +14 -2
- package/cli/sync-lock.js +105 -0
- package/cli/sync.js +14 -6
- package/package.json +6 -3
- package/references/cli.md +22 -0
- package/references/local-crm-draft-handoff.md +76 -0
- package/references/portable-ledger-handoff.md +50 -0
- package/references/recipes/catalog.json +9 -9
- package/references/recipes/command-notes.md +17 -1
- package/references/recipes/placement-reconciliation.md +19 -5
- package/references/recipes/qualified-campaign-handoff.md +16 -4
- package/references/recipes/site-context-brief.md +2 -2
- package/references/recipes/sourced-linkable-asset.md +8 -8
- package/scripts/agentlinkops.py +30 -5
- package/scripts/outreach-crm-draft.py +121 -0
- package/scripts/portable-handoff.py +184 -0
- package/shared/lifecycle-contract.js +2 -2
- package/shared/response-contract.js +7 -1
- package/skills/agentlinkops-assets/SKILL.md +2 -2
- package/skills/agentlinkops-campaigns/SKILL.md +3 -3
- package/skills/agentlinkops-connect/SKILL.md +29 -6
- package/skills/agentlinkops-connect/references/agentlinkops.md +34 -13
- package/skills/agentlinkops-crm/SKILL.md +17 -3
- package/src/citations/adapter.js +3 -2
- package/src/citations/browser/engine.js +23 -7
- package/src/citations/login.js +42 -5
- package/src/discovery/contract.js +1 -1
- package/src/response-shaping.js +58 -0
- package/src/tool-views.js +3 -1
package/src/citations/login.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// it's never synced to the hosted service, and `citation logout ENGINE`
|
|
9
9
|
// removes it. This is the CLI-own-browser posture — the user's account,
|
|
10
10
|
// their risk, their data.
|
|
11
|
-
import
|
|
11
|
+
import * as fs from 'node:fs/promises';
|
|
12
|
+
import { rm } from 'node:fs/promises';
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
12
14
|
import { join } from 'node:path';
|
|
13
15
|
import { homedir } from 'node:os';
|
|
14
16
|
import { readFile } from 'node:fs/promises';
|
|
@@ -25,6 +27,44 @@ const ENGINE_URLS = Object.freeze({
|
|
|
25
27
|
bing: 'https://www.bing.com/',
|
|
26
28
|
});
|
|
27
29
|
|
|
30
|
+
async function saveSession(sessionDir, engineName, state, { sessionFs = fs, platform = process.platform } = {}) {
|
|
31
|
+
const fail = () => Object.assign(new Error('Session storage requires a real private directory (0700) and regular private files (0600). Choose private storage and remove symbolic links.'), { code: 'PRIVATE_STORAGE_REQUIRED' });
|
|
32
|
+
const privateMode = (info, mode) => platform === 'win32' || (info.mode & 0o7777) === mode;
|
|
33
|
+
await sessionFs.mkdir(sessionDir, { recursive: true, mode: 0o700 });
|
|
34
|
+
const directory = await sessionFs.lstat(sessionDir);
|
|
35
|
+
if (!directory.isDirectory() || !privateMode(directory, 0o700)) throw fail();
|
|
36
|
+
const sessionPath = join(sessionDir, `${engineName}.json`);
|
|
37
|
+
const inspect = async () => {
|
|
38
|
+
try {
|
|
39
|
+
const info = await sessionFs.lstat(sessionPath);
|
|
40
|
+
if (!info.isFile() || !privateMode(info, 0o600)) throw fail();
|
|
41
|
+
return info;
|
|
42
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
43
|
+
};
|
|
44
|
+
const previous = await inspect();
|
|
45
|
+
const temporary = join(sessionDir, `.${engineName}.${randomUUID()}.tmp`);
|
|
46
|
+
let handle, owned = false;
|
|
47
|
+
try {
|
|
48
|
+
handle = await sessionFs.open(temporary, 'wx', 0o600); owned = true;
|
|
49
|
+
const info = await handle.stat();
|
|
50
|
+
if (!info.isFile() || !privateMode(info, 0o600)) throw fail();
|
|
51
|
+
await handle.writeFile(JSON.stringify(state, null, 2));
|
|
52
|
+
await handle.sync();
|
|
53
|
+
await handle.close(); handle = undefined;
|
|
54
|
+
const currentDirectory = await sessionFs.lstat(sessionDir);
|
|
55
|
+
if (!currentDirectory.isDirectory() || !privateMode(currentDirectory, 0o700) || currentDirectory.dev !== directory.dev || currentDirectory.ino !== directory.ino) throw fail();
|
|
56
|
+
const current = await inspect();
|
|
57
|
+
if (Boolean(current) !== Boolean(previous) || (current && ['dev', 'ino', 'size', 'mtimeMs'].some(key => current[key] !== previous[key]))) {
|
|
58
|
+
throw Object.assign(new Error('The saved session changed during login. Retry without another session writer.'), { code: 'SESSION_CHANGED' });
|
|
59
|
+
}
|
|
60
|
+
await sessionFs.rename(temporary, sessionPath); owned = false;
|
|
61
|
+
return sessionPath;
|
|
62
|
+
} finally {
|
|
63
|
+
await handle?.close().catch(() => {});
|
|
64
|
+
if (owned) await sessionFs.unlink(temporary).catch(() => {});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
28
68
|
export async function loginEngine({ engineName, timeoutMs = 180_000, runtime = {} }) {
|
|
29
69
|
const url = ENGINE_URLS[engineName];
|
|
30
70
|
if (!url) {
|
|
@@ -80,10 +120,7 @@ export async function loginEngine({ engineName, timeoutMs = 180_000, runtime = {
|
|
|
80
120
|
// (anonymous surfaces don't need login, and the adapter handles both).
|
|
81
121
|
const state = await context.storageState();
|
|
82
122
|
const sessionDir = runtime.sessionDir ?? SESSIONS_DIR;
|
|
83
|
-
await
|
|
84
|
-
const sessionPath = join(sessionDir, `${engineName}.json`);
|
|
85
|
-
await writeFile(sessionPath, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
86
|
-
await chmod(sessionPath, 0o600);
|
|
123
|
+
const sessionPath = await saveSession(sessionDir, engineName, state, runtime);
|
|
87
124
|
|
|
88
125
|
return {
|
|
89
126
|
engine: engineName,
|
|
@@ -64,7 +64,7 @@ export const DiscoveryQuery = z.strictObject({
|
|
|
64
64
|
row_limit: count.min(1).max(DISCOVERY_LIMITS.rows),
|
|
65
65
|
}).superRefine((q, ctx) => {
|
|
66
66
|
const url = validatePublicUrl(q.target_kind === 'domain' ? `https://${q.target}/` : q.target);
|
|
67
|
-
if (!url.valid || (q.target_kind === 'domain' ? url.hostname !== q.target
|
|
67
|
+
if (!url.valid || (q.target_kind === 'domain' ? url.hostname !== q.target : url.url !== q.target))
|
|
68
68
|
ctx.addIssue({ code: 'custom', message: 'Invalid or noncanonical discovery target.', path: ['target'] });
|
|
69
69
|
if (q.target_kind === 'exact_url' && q.include_subdomains)
|
|
70
70
|
ctx.addIssue({ code: 'custom', message: 'Exact URL scope cannot include subdomains.', path: ['include_subdomains'] });
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// DP-0036-T15: response shaping. A list or report command whose rows carry evidence has a
|
|
2
|
+
// reviewed CONCISE projection: the fields an agent needs to decide and to call next, with the
|
|
3
|
+
// observation facts the unknown-versus-absent rule depends on always kept (state, uncertain,
|
|
4
|
+
// the latest attempt and its reason, the last verified observation). MCP and code mode default
|
|
5
|
+
// to concise; REST and the CLI default to detailed; every caller can ask for either with
|
|
6
|
+
// `format`. Rows only are projected: top-level envelope fields (cursors, coverage, counts,
|
|
7
|
+
// reservations, run and inventory metadata) are never dropped. Rules: AGENT-SURFACE-CONTRACT.md.
|
|
8
|
+
|
|
9
|
+
export const FORMATS = ['concise','detailed'];
|
|
10
|
+
|
|
11
|
+
const pick = (row, keys) => Object.fromEntries(keys.filter(key => row[key] !== undefined).map(key => [key, row[key]]));
|
|
12
|
+
// The evidence core of an observation_state or an attempt: never more than these, never less.
|
|
13
|
+
const attempt = value => value && typeof value === 'object' ? pick(value, ['state','reason','checkedAt','httpStatus','occurrenceCount','checkerVersion']) : value;
|
|
14
|
+
export function conciseObservationState(state) {
|
|
15
|
+
if (!state || typeof state !== 'object') return state ?? {};
|
|
16
|
+
const out = pick(state, ['state','uncertain','checked_at','reason','confirmedState','lastChangedAt','firstAbsentAt','consecutiveAbsent','nextCheckAt','lastSuccessfulAt','lastHealthyAt','firstUnavailableAt','unavailableCount','retryNotBefore']);
|
|
17
|
+
if (state.latestAttempt !== undefined) out.latestAttempt = attempt(state.latestAttempt);
|
|
18
|
+
if (state.lastSuccessfulObservation !== undefined) out.lastSuccessfulObservation = attempt(state.lastSuccessfulObservation);
|
|
19
|
+
if (state.lastSuccessfulObservationSameAsLatestAttempt !== undefined) out.lastSuccessfulObservationSameAsLatestAttempt = state.lastSuccessfulObservationSameAsLatestAttempt;
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
const withState = (row, keys) => ({ ...pick(row, keys), observation_state: conciseObservationState(row.observation_state) });
|
|
23
|
+
// An event snapshot (shared/observation-event.js) minus the occurrence list, expectations and
|
|
24
|
+
// signatures: the state, uncertainty, check state, reason, status and timing stay.
|
|
25
|
+
const conciseSnapshot = snapshot => snapshot && typeof snapshot === 'object'
|
|
26
|
+
? pick(snapshot, ['state','uncertain','check_state','reason','checked_at','http_status','occurrence_count','occurrences_truncated','source_url','target_url','url','next_check_at','watch_id','target_id'])
|
|
27
|
+
: snapshot;
|
|
28
|
+
const observationResult = result => result && typeof result === 'object'
|
|
29
|
+
? { ...pick(result, ['state','reason','checkedAt','httpStatus','checkerVersion','evidence','redirects']), occurrenceCount: result.occurrenceCount ?? result.occurrences?.length ?? 0 }
|
|
30
|
+
: result;
|
|
31
|
+
|
|
32
|
+
// One entry per shaped command: which array holds the rows and how a row is projected. The
|
|
33
|
+
// `keeps` list is what the docs and the tests read; `row` is the projection itself.
|
|
34
|
+
export const SHAPES = {
|
|
35
|
+
list_rank_history: { rows: 'items', keeps: ['overview_run_id','slot_at','rank','observed_at','retrieved_at','freshness','outcome','execution','cost'],
|
|
36
|
+
row: r => pick(r, ['overview_run_id','slot_at','rank','observed_at','retrieved_at','freshness','outcome','execution','cost']) },
|
|
37
|
+
list_link_watches: { rows: 'items', keeps: ['id','source_url','target_url','status','state','observation_state','next_check_at','local_reference'], row: r => withState(r, ['id','source_url','target_url','status','state','next_check_at','local_reference']) },
|
|
38
|
+
list_targets: { rows: 'items', keeps: ['id','url','status','state','observation_state','next_check_at'], row: r => withState(r, ['id','url','status','state','next_check_at']) },
|
|
39
|
+
get_target_placements: { rows: 'items', keeps: ['id','source_url','target_url','target_scope','status','state'], row: r => pick(r, ['id','source_url','target_url','target_scope','status','state']) },
|
|
40
|
+
list_check_jobs: { rows: 'items', keeps: ['id','state','type','watch_id','target_id','attempt_count','error_code','created_at','completed_at'], row: r => pick(r, ['id','state','type','watch_id','target_id','attempt_count','error_code','created_at','completed_at']) },
|
|
41
|
+
get_history: { rows: 'items', keeps: ['id','state','reason','checked_at','job_id','evidence_key','result (state, reason, checkedAt, httpStatus, checkerVersion, evidence, redirects, occurrenceCount)'], row: r => ({ ...pick(r, ['id','state','reason','checked_at','job_id','evidence_key']), result: observationResult(r.result) }) },
|
|
42
|
+
list_events: { rows: 'events', keeps: ['id','type','watch_id','target_id','created_at','cursor','data (before and after snapshots without occurrences, expectations and signatures; observation_id; evidence_key)'], row: r => ({ ...pick(r, ['id','type','watch_id','target_id','created_at','cursor']), data: r.data && typeof r.data === 'object' ? { ...pick(r.data, ['observation_id','evidence_key']), ...(r.data.before !== undefined ? { before: conciseSnapshot(r.data.before) } : {}), ...(r.data.after !== undefined ? { after: conciseSnapshot(r.data.after) } : {}) } : r.data }) },
|
|
43
|
+
list_discovery_candidates: { rows: 'items', keeps: ['id','source_url','target_url','anchor','dofollow','provider_last_seen','verification_status','verified_at','observation_id'], row: r => pick(r, ['id','source_url','target_url','anchor','dofollow','provider_last_seen','verification_status','verified_at','observation_id']) },
|
|
44
|
+
list_competitor_inventory_rows: { rows: 'items', keeps: ['id','source_url','target_url','anchor','dofollow','provider_last_seen','verification_status','observation_id','member_role'], row: r => pick(r, ['id','source_url','target_url','anchor','dofollow','provider_last_seen','verification_status','observation_id','member_role']) },
|
|
45
|
+
get_candidate_verification_batch: { rows: 'items', keeps: ['ordinal','candidate_id','local_reference','job_id','watch_id','state','error_code','observation','usage'], row: r => pick(r, ['ordinal','candidate_id','local_reference','job_id','watch_id','state','error_code','observation','usage']) },
|
|
46
|
+
};
|
|
47
|
+
export const SHAPED_COMMANDS = Object.keys(SHAPES);
|
|
48
|
+
|
|
49
|
+
// Apply the requested (or defaulted) format. Detailed returns the data untouched apart from
|
|
50
|
+
// `defaults_applied`; concise projects the rows. Both formats say which defaults were applied.
|
|
51
|
+
export function shapeResponse(name, data, { format = null, defaultFormat = 'detailed' } = {}) {
|
|
52
|
+
const shape = SHAPES[name];
|
|
53
|
+
if (!shape || !data || typeof data !== 'object' || Array.isArray(data)) return data;
|
|
54
|
+
const applied = format ?? defaultFormat;
|
|
55
|
+
const defaults = format ? {} : { format: defaultFormat };
|
|
56
|
+
if (applied !== 'concise' || !Array.isArray(data[shape.rows])) return { ...data, defaults_applied: defaults };
|
|
57
|
+
return { ...data, [shape.rows]: data[shape.rows].map(row => row && typeof row === 'object' ? shape.row(row) : row), defaults_applied: defaults };
|
|
58
|
+
}
|
package/src/tool-views.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// script, the MCP router, the CLI index and the generators all read the same definitions.
|
|
4
4
|
// Rules: docs/technical/AGENT-SURFACE-CONTRACT.md (DP-0036).
|
|
5
5
|
|
|
6
|
-
export const TOOLSETS = ['monitoring','evidence','discovery','library','competitors','reports','workspace','notifications','webhooks','admission','disavow','lifecycle','digests'];
|
|
6
|
+
export const TOOLSETS = ['monitoring','evidence','rank','discovery','baseline','library','competitors','reports','workspace','notifications','webhooks','admission','disavow','lifecycle','digests'];
|
|
7
7
|
|
|
8
8
|
// One line per toolset: what the group is for. Printed by the site brief, the REST index and the
|
|
9
9
|
// catalog snapshot so the same wording reaches every client; a toolset without a summary is a
|
|
@@ -15,7 +15,9 @@ export const TOOLSET_SUMMARIES = {
|
|
|
15
15
|
digests: 'Own-address digest preferences, delivery history and exact events.',
|
|
16
16
|
monitoring: 'Watch earned links and destination URLs: create, list, update, import, export, recheck.',
|
|
17
17
|
evidence: 'What a check observed: histories, snapshots, change feeds, check jobs, published contacts.',
|
|
18
|
+
rank: 'Synthetic DataForSEO rank schedules and dated history; hosted collection disabled.',
|
|
18
19
|
discovery: 'Import candidate rows, read stored runs, verify selected candidates, enroll them.',
|
|
20
|
+
baseline: 'Scoped backlink baselines; supplier admission disabled.',
|
|
19
21
|
library: 'Read and export the coverage-stated opportunity library.',
|
|
20
22
|
competitors: 'Competitor sets, dated inventories, scheduled refresh, gap and domain-mix reports.',
|
|
21
23
|
reports: 'Profile, anchor and report summaries over the tracked dataset.',
|