@patimweb/pi-outline-wiki 1.0.0
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/.node-version +1 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/index.ts +128 -0
- package/package.json +51 -0
- package/release.config.cjs +16 -0
- package/screenshot.png +0 -0
- package/skills/outline-doc-writing/SKILL.md +78 -0
- package/skills/outline-knowledge-capture/SKILL.md +92 -0
- package/skills/outline-wiki/SKILL.md +70 -0
- package/src/clients/outline-client.ts +632 -0
- package/src/config.ts +218 -0
- package/src/formatting/formatters.ts +258 -0
- package/src/tools/outline-archive.ts +70 -0
- package/src/tools/outline-collections.ts +52 -0
- package/src/tools/outline-comment.ts +65 -0
- package/src/tools/outline-comments.ts +52 -0
- package/src/tools/outline-create.ts +99 -0
- package/src/tools/outline-delete.ts +60 -0
- package/src/tools/outline-export.ts +146 -0
- package/src/tools/outline-list.ts +110 -0
- package/src/tools/outline-move.ts +99 -0
- package/src/tools/outline-profile.ts +82 -0
- package/src/tools/outline-read.ts +104 -0
- package/src/tools/outline-search.ts +114 -0
- package/src/tools/outline-setup.ts +71 -0
- package/src/tools/outline-status.ts +64 -0
- package/src/tools/outline-structure.ts +63 -0
- package/src/tools/outline-update.ts +106 -0
- package/src/types.ts +169 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration persistence and state.
|
|
3
|
+
*
|
|
4
|
+
* Stores named Outline workspace profiles in ~/.pi/outline-config.json.
|
|
5
|
+
* Supports multiple profiles with an active profile selector.
|
|
6
|
+
*
|
|
7
|
+
* File format:
|
|
8
|
+
* { "profiles": { "name": OutlineConfig, ... }, "activeProfile": "name" }
|
|
9
|
+
*
|
|
10
|
+
* Backward-compatible: a flat { url, apiKey } object is migrated to
|
|
11
|
+
* { profiles: { "default": ... }, activeProfile: "default" }.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { OutlineConfig, OutlineProfiles } from "./types.ts";
|
|
15
|
+
import { OutlineNotConfiguredError } from "./types.ts";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
|
|
19
|
+
// Mutable state
|
|
20
|
+
|
|
21
|
+
let profiles: Record<string, OutlineConfig> = {};
|
|
22
|
+
let activeProfile: string | null = null;
|
|
23
|
+
|
|
24
|
+
// Path resolution
|
|
25
|
+
|
|
26
|
+
function configPath(): string {
|
|
27
|
+
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
|
28
|
+
return path.join(home, ".pi", "outline-config.json");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// URL normalisation
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Accepts what people actually paste: a bare host, a URL with a trailing
|
|
35
|
+
* slash, the `/api` base, or the `/mcp` endpoint from Outline's MCP docs.
|
|
36
|
+
* Returns the workspace root without a trailing slash.
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeBaseUrl(raw: string): string {
|
|
39
|
+
let url = String(raw ?? "").trim();
|
|
40
|
+
if (!url) {
|
|
41
|
+
throw new Error("Workspace URL must not be empty.");
|
|
42
|
+
}
|
|
43
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
44
|
+
url = `https://${url}`;
|
|
45
|
+
}
|
|
46
|
+
url = url.replace(/\/+$/, "");
|
|
47
|
+
url = url.replace(/\/(api|mcp)$/i, "");
|
|
48
|
+
url = url.replace(/\/+$/, "");
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// Throws on malformed input, so setup fails loudly instead of at call time.
|
|
52
|
+
new URL(url);
|
|
53
|
+
} catch {
|
|
54
|
+
throw new Error(`"${raw}" is not a valid workspace URL.`);
|
|
55
|
+
}
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Persistence
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Write the profile store.
|
|
63
|
+
*
|
|
64
|
+
* The file holds a plaintext API key, so it must never be group- or
|
|
65
|
+
* world-readable. writeFileSync's `mode` only applies when the file is
|
|
66
|
+
* created, so an existing file keeps its old (possibly 0644) permissions --
|
|
67
|
+
* we therefore write to a private temp file and rename it into place. That
|
|
68
|
+
* is also atomic, so a crash mid-write cannot truncate the config.
|
|
69
|
+
*/
|
|
70
|
+
function persistProfiles(): void {
|
|
71
|
+
const filePath = configPath();
|
|
72
|
+
const dir = path.dirname(filePath);
|
|
73
|
+
if (!fs.existsSync(dir)) {
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const data: OutlineProfiles = { profiles, activeProfile };
|
|
78
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), {
|
|
82
|
+
encoding: "utf-8",
|
|
83
|
+
mode: 0o600,
|
|
84
|
+
});
|
|
85
|
+
fs.chmodSync(tmpPath, 0o600);
|
|
86
|
+
fs.renameSync(tmpPath, filePath);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
try {
|
|
89
|
+
fs.unlinkSync(tmpPath);
|
|
90
|
+
} catch {
|
|
91
|
+
/* ignore */
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function loadConfig(): void {
|
|
98
|
+
try {
|
|
99
|
+
const filePath = configPath();
|
|
100
|
+
if (!fs.existsSync(filePath)) {
|
|
101
|
+
profiles = {};
|
|
102
|
+
activeProfile = null;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
107
|
+
|
|
108
|
+
// Tighten permissions on config files written by older versions.
|
|
109
|
+
try {
|
|
110
|
+
const mode = fs.statSync(filePath).mode & 0o777;
|
|
111
|
+
if (mode !== 0o600) fs.chmodSync(filePath, 0o600);
|
|
112
|
+
} catch {
|
|
113
|
+
/* ignore */
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (raw && typeof raw === "object" && raw.apiKey && !("profiles" in raw)) {
|
|
117
|
+
profiles = { default: raw as OutlineConfig };
|
|
118
|
+
activeProfile = "default";
|
|
119
|
+
persistProfiles();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (raw && typeof raw === "object" && raw.profiles) {
|
|
124
|
+
profiles = raw.profiles;
|
|
125
|
+
if (raw.activeProfile && profiles[raw.activeProfile]) {
|
|
126
|
+
activeProfile = raw.activeProfile;
|
|
127
|
+
} else if (Object.keys(profiles).length > 0) {
|
|
128
|
+
activeProfile = Object.keys(profiles)[0];
|
|
129
|
+
} else {
|
|
130
|
+
activeProfile = null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
profiles = {};
|
|
135
|
+
activeProfile = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Access
|
|
140
|
+
|
|
141
|
+
export function getConfig(): OutlineConfig | null {
|
|
142
|
+
if (!activeProfile) return null;
|
|
143
|
+
return profiles[activeProfile] ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function getConfigOrThrow(): OutlineConfig {
|
|
147
|
+
const config = getConfig();
|
|
148
|
+
if (!config) {
|
|
149
|
+
throw new OutlineNotConfiguredError();
|
|
150
|
+
}
|
|
151
|
+
return config;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getProfile(name: string): OutlineConfig | null {
|
|
155
|
+
return profiles[name] ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve a profile parameter: if a name is given, return that profile
|
|
160
|
+
* (or throw if not found). Otherwise, return the active profile or throw.
|
|
161
|
+
*/
|
|
162
|
+
export function resolveConfig(profileName?: string): OutlineConfig {
|
|
163
|
+
if (profileName) {
|
|
164
|
+
const config = getProfile(profileName);
|
|
165
|
+
if (!config) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Profile "${profileName}" not found. Available: ${Object.keys(profiles).join(", ") || "none"}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return config;
|
|
171
|
+
}
|
|
172
|
+
return getConfigOrThrow();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function getProfiles(): Record<string, OutlineConfig> {
|
|
176
|
+
return { ...profiles };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function getActiveProfile(): string | null {
|
|
180
|
+
return activeProfile;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Mutations
|
|
184
|
+
|
|
185
|
+
export function saveProfile(name: string, config: OutlineConfig): void {
|
|
186
|
+
profiles[name] = { ...config, url: normalizeBaseUrl(config.url) };
|
|
187
|
+
if (!activeProfile || Object.keys(profiles).length === 1) {
|
|
188
|
+
activeProfile = name;
|
|
189
|
+
}
|
|
190
|
+
persistProfiles();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function setActiveProfile(name: string): void {
|
|
194
|
+
if (!profiles[name]) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Profile "${name}" does not exist. Available: ${Object.keys(profiles).join(", ") || "none"}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
activeProfile = name;
|
|
200
|
+
persistProfiles();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function deleteProfile(name: string): boolean {
|
|
204
|
+
if (!profiles[name]) return false;
|
|
205
|
+
delete profiles[name];
|
|
206
|
+
if (activeProfile === name) {
|
|
207
|
+
const keys = Object.keys(profiles);
|
|
208
|
+
activeProfile = keys.length > 0 ? keys[0] : null;
|
|
209
|
+
}
|
|
210
|
+
persistProfiles();
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @internal Reset state -- for testing only */
|
|
215
|
+
export function _resetForTesting(): void {
|
|
216
|
+
profiles = {};
|
|
217
|
+
activeProfile = null;
|
|
218
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatters.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that transform domain data into display strings.
|
|
5
|
+
* No emojis, no side effects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
CollectionSummary,
|
|
10
|
+
CommentEntry,
|
|
11
|
+
DocumentDetail,
|
|
12
|
+
DocumentSummary,
|
|
13
|
+
NavigationNode,
|
|
14
|
+
OutlineConfig,
|
|
15
|
+
Pagination,
|
|
16
|
+
SearchHit,
|
|
17
|
+
WorkspaceInfo,
|
|
18
|
+
} from "../types.ts";
|
|
19
|
+
|
|
20
|
+
/** Show only the last four characters of an API key. */
|
|
21
|
+
export function maskApiKey(apiKey: string): string {
|
|
22
|
+
if (!apiKey) return "(none)";
|
|
23
|
+
if (apiKey.length <= 8) return "****";
|
|
24
|
+
return `${apiKey.slice(0, 7)}...${apiKey.slice(-4)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function shortDate(value?: string | null): string {
|
|
28
|
+
if (!value) return "-";
|
|
29
|
+
const date = new Date(value);
|
|
30
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
31
|
+
return date.toISOString().slice(0, 16).replace("T", " ");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function statusMarkers(doc: DocumentSummary): string {
|
|
35
|
+
const markers: string[] = [];
|
|
36
|
+
if (doc.deletedAt) markers.push("trashed");
|
|
37
|
+
else if (doc.archivedAt) markers.push("archived");
|
|
38
|
+
else if (!doc.publishedAt) markers.push("draft");
|
|
39
|
+
if (doc.template) markers.push("template");
|
|
40
|
+
return markers.length > 0 ? ` [${markers.join(",")}]` : "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function paginationNote(pagination?: Pagination, shown = 0): string {
|
|
44
|
+
if (!pagination) return "";
|
|
45
|
+
const offset = pagination.offset ?? 0;
|
|
46
|
+
const limit = pagination.limit ?? shown;
|
|
47
|
+
if (shown < limit) return "";
|
|
48
|
+
return `\n\nMore results may exist. Re-run with offset ${offset + limit}.`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Profiles
|
|
52
|
+
|
|
53
|
+
export function formatProfileStatus(
|
|
54
|
+
profiles: Record<string, OutlineConfig>,
|
|
55
|
+
active: string | null,
|
|
56
|
+
): string {
|
|
57
|
+
const names = Object.keys(profiles);
|
|
58
|
+
if (names.length === 0) {
|
|
59
|
+
return "No Outline workspace configured. Use outline_setup with your workspace URL and an API key from Settings -> API & Apps.";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lines = names.map((name) => {
|
|
63
|
+
const config = profiles[name];
|
|
64
|
+
const marker = name === active ? "*" : " ";
|
|
65
|
+
const insecure = config.insecureTls ? " (TLS validation off)" : "";
|
|
66
|
+
return `${marker} ${name}: ${config.url} key ${maskApiKey(config.apiKey)}${insecure}`;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
`Outline profiles (${names.length}), * marks the active one:`,
|
|
71
|
+
...lines,
|
|
72
|
+
].join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatWorkspaceInfo(info: WorkspaceInfo): string {
|
|
76
|
+
const lines = [
|
|
77
|
+
`Workspace: ${info.teamName ?? "(unknown)"}`,
|
|
78
|
+
`URL: ${info.teamUrl ?? "(unknown)"}`,
|
|
79
|
+
`Authenticated as: ${info.userName ?? "(unknown)"}${info.userEmail ? ` <${info.userEmail}>` : ""}`,
|
|
80
|
+
];
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Collections
|
|
85
|
+
|
|
86
|
+
export function formatCollectionList(
|
|
87
|
+
collections: ReadonlyArray<CollectionSummary>,
|
|
88
|
+
): string {
|
|
89
|
+
if (collections.length === 0) {
|
|
90
|
+
return "No collections found. The API key's user may not be a member of any collection.";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lines = collections.map((collection) => {
|
|
94
|
+
const description = collection.description
|
|
95
|
+
? ` -- ${collection.description.replace(/\s+/g, " ").slice(0, 100)}`
|
|
96
|
+
: "";
|
|
97
|
+
return `- ${collection.name} (id ${collection.id})${description}`;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return [`Collections (${collections.length}):`, ...lines].join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function formatCollectionStructure(
|
|
104
|
+
nodes: ReadonlyArray<NavigationNode>,
|
|
105
|
+
collectionName?: string,
|
|
106
|
+
): string {
|
|
107
|
+
if (nodes.length === 0) {
|
|
108
|
+
return `Collection ${collectionName ?? ""} has no documents.`.trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lines: string[] = [];
|
|
112
|
+
function walk(items: ReadonlyArray<NavigationNode>, indent: string): void {
|
|
113
|
+
for (const node of items) {
|
|
114
|
+
lines.push(`${indent}- ${node.title} (id ${node.id})`);
|
|
115
|
+
if (node.children.length > 0) {
|
|
116
|
+
walk(node.children, `${indent} `);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
walk(nodes, "");
|
|
121
|
+
|
|
122
|
+
const header = collectionName
|
|
123
|
+
? `Document tree of "${collectionName}":`
|
|
124
|
+
: "Document tree:";
|
|
125
|
+
return [header, ...lines].join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Documents
|
|
129
|
+
|
|
130
|
+
export function formatDocumentList(
|
|
131
|
+
documents: ReadonlyArray<DocumentSummary>,
|
|
132
|
+
heading: string,
|
|
133
|
+
pagination?: Pagination,
|
|
134
|
+
): string {
|
|
135
|
+
if (documents.length === 0) {
|
|
136
|
+
return `${heading}: nothing found.`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const lines = documents.map((doc) => {
|
|
140
|
+
const author = doc.updatedBy?.name ? ` by ${doc.updatedBy.name}` : "";
|
|
141
|
+
return `- ${doc.title}${statusMarkers(doc)}\n id ${doc.id} | updated ${shortDate(doc.updatedAt)}${author}`;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
`${heading} (${documents.length}):\n` +
|
|
146
|
+
lines.join("\n") +
|
|
147
|
+
paginationNote(pagination, documents.length)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function formatSearchResults(
|
|
152
|
+
hits: ReadonlyArray<SearchHit>,
|
|
153
|
+
query: string,
|
|
154
|
+
pagination?: Pagination,
|
|
155
|
+
): string {
|
|
156
|
+
if (hits.length === 0) {
|
|
157
|
+
return `No documents match "${query}". Try fewer or more general keywords, or drop the collection filter.`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const lines = hits.map((hit, index) => {
|
|
161
|
+
const context = hit.context.replace(/\s+/g, " ").trim();
|
|
162
|
+
return [
|
|
163
|
+
`${index + 1}. ${hit.document.title}${statusMarkers(hit.document)}`,
|
|
164
|
+
` id ${hit.document.id} | updated ${shortDate(hit.document.updatedAt)}`,
|
|
165
|
+
context ? ` ${context.slice(0, 300)}` : "",
|
|
166
|
+
]
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
.join("\n");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
`Search "${query}" -- ${hits.length} result(s). Read one with outline_read.\n` +
|
|
173
|
+
lines.join("\n") +
|
|
174
|
+
paginationNote(pagination, hits.length)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function formatDocument(
|
|
179
|
+
doc: DocumentDetail,
|
|
180
|
+
options: { maxChars?: number } = {},
|
|
181
|
+
): string {
|
|
182
|
+
const maxChars = options.maxChars ?? 0;
|
|
183
|
+
let body = doc.text || "(this document has no content yet)";
|
|
184
|
+
let truncated = false;
|
|
185
|
+
|
|
186
|
+
if (maxChars > 0 && body.length > maxChars) {
|
|
187
|
+
body = body.slice(0, maxChars);
|
|
188
|
+
truncated = true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const header = [
|
|
192
|
+
`Title: ${doc.title}${statusMarkers(doc)}`,
|
|
193
|
+
`Id: ${doc.id}`,
|
|
194
|
+
doc.collectionId ? `Collection: ${doc.collectionId}` : "",
|
|
195
|
+
doc.parentDocumentId ? `Parent document: ${doc.parentDocumentId}` : "",
|
|
196
|
+
doc.url ? `URL: ${doc.url}` : "",
|
|
197
|
+
`Updated: ${shortDate(doc.updatedAt)}${doc.updatedBy?.name ? ` by ${doc.updatedBy.name}` : ""}`,
|
|
198
|
+
].filter(Boolean);
|
|
199
|
+
|
|
200
|
+
const footer = truncated
|
|
201
|
+
? `\n\n[truncated after ${maxChars} characters -- re-run outline_read with a larger maxChars to see the rest]`
|
|
202
|
+
: "";
|
|
203
|
+
|
|
204
|
+
return `${header.join("\n")}\n\n---\n\n${body}${footer}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function formatDocumentSaved(
|
|
208
|
+
doc: DocumentSummary,
|
|
209
|
+
action: "created" | "updated",
|
|
210
|
+
): string {
|
|
211
|
+
const state = doc.publishedAt ? "published" : "draft";
|
|
212
|
+
return [
|
|
213
|
+
`Document ${action} (${state}): ${doc.title}`,
|
|
214
|
+
`Id: ${doc.id}`,
|
|
215
|
+
doc.url ? `URL: ${doc.url}` : "",
|
|
216
|
+
]
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Comments
|
|
222
|
+
|
|
223
|
+
export function formatComments(
|
|
224
|
+
comments: ReadonlyArray<CommentEntry>,
|
|
225
|
+
documentId: string,
|
|
226
|
+
): string {
|
|
227
|
+
if (comments.length === 0) {
|
|
228
|
+
return `No comments on document ${documentId}.`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const byParent = new Map<string, CommentEntry[]>();
|
|
232
|
+
const roots: CommentEntry[] = [];
|
|
233
|
+
for (const comment of comments) {
|
|
234
|
+
if (comment.parentCommentId) {
|
|
235
|
+
const siblings = byParent.get(comment.parentCommentId) ?? [];
|
|
236
|
+
siblings.push(comment);
|
|
237
|
+
byParent.set(comment.parentCommentId, siblings);
|
|
238
|
+
} else {
|
|
239
|
+
roots.push(comment);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const lines: string[] = [];
|
|
244
|
+
function render(entry: CommentEntry, indent: string): void {
|
|
245
|
+
const author = entry.createdBy?.name ?? "unknown";
|
|
246
|
+
const resolved = entry.resolvedAt ? " [resolved]" : "";
|
|
247
|
+
lines.push(`${indent}- ${author} at ${shortDate(entry.createdAt)}${resolved} (id ${entry.id})`);
|
|
248
|
+
for (const line of (entry.text || "(empty)").split("\n")) {
|
|
249
|
+
lines.push(`${indent} ${line}`);
|
|
250
|
+
}
|
|
251
|
+
for (const child of byParent.get(entry.id) ?? []) {
|
|
252
|
+
render(child, `${indent} `);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const root of roots) render(root, "");
|
|
256
|
+
|
|
257
|
+
return [`Comments on document ${documentId} (${comments.length}):`, ...lines].join("\n");
|
|
258
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outline_archive tool -- Archive, restore or unpublish a document.
|
|
3
|
+
*
|
|
4
|
+
* Archiving is the reversible way to retire a wiki page: it disappears from
|
|
5
|
+
* the collection but stays searchable in the archive.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { resolveConfig } from "../config.ts";
|
|
10
|
+
import { documentLifecycle } from "../clients/outline-client.ts";
|
|
11
|
+
|
|
12
|
+
const ACTIONS: Record<string, "archive" | "restore" | "unpublish"> = {
|
|
13
|
+
archive: "archive",
|
|
14
|
+
restore: "restore",
|
|
15
|
+
unarchive: "restore",
|
|
16
|
+
unpublish: "unpublish",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const OutlineArchiveTool = {
|
|
20
|
+
name: "outline_archive",
|
|
21
|
+
label: "Outline Archive Document",
|
|
22
|
+
description:
|
|
23
|
+
"Archive a document (reversible, keeps it out of the collection), restore an archived or trashed document, or unpublish a document back into a draft. Use outline_delete only when the page should really go away.",
|
|
24
|
+
parameters: Type.Object({
|
|
25
|
+
id: Type.String({ description: "Document id." }),
|
|
26
|
+
action: Type.Optional(
|
|
27
|
+
Type.String({
|
|
28
|
+
description: "One of: archive (default), restore, unpublish.",
|
|
29
|
+
default: "archive",
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
profile: Type.Optional(
|
|
33
|
+
Type.String({ description: "Outline profile to use. Defaults to the active one." }),
|
|
34
|
+
),
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
async execute(
|
|
38
|
+
_toolCallId: string,
|
|
39
|
+
params: { id: string; action?: string; profile?: string },
|
|
40
|
+
signal: AbortSignal,
|
|
41
|
+
) {
|
|
42
|
+
const requested = (params.action ?? "archive").toLowerCase();
|
|
43
|
+
const action = ACTIONS[requested];
|
|
44
|
+
if (!action) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Unknown action "${params.action}". Use archive, restore or unpublish.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const config = resolveConfig(params.profile);
|
|
51
|
+
const doc = await documentLifecycle(config, action, { id: params.id }, signal);
|
|
52
|
+
|
|
53
|
+
const done =
|
|
54
|
+
action === "archive"
|
|
55
|
+
? "archived"
|
|
56
|
+
: action === "restore"
|
|
57
|
+
? "restored"
|
|
58
|
+
: "unpublished (now a draft)";
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
content: [
|
|
62
|
+
{
|
|
63
|
+
type: "text" as const,
|
|
64
|
+
text: `Document ${doc?.title ?? params.id} ${done}.\nId: ${doc?.id ?? params.id}`,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
details: { id: doc?.id ?? params.id, action, title: doc?.title },
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outline_collections tool -- List the collections of the workspace.
|
|
3
|
+
*
|
|
4
|
+
* Collections are the top level of an Outline wiki; every published document
|
|
5
|
+
* lives in exactly one. Most write tools need a collectionId, so this is
|
|
6
|
+
* usually the first read call in a session.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { resolveConfig } from "../config.ts";
|
|
11
|
+
import { listCollections } from "../clients/outline-client.ts";
|
|
12
|
+
import { formatCollectionList } from "../formatting/formatters.ts";
|
|
13
|
+
|
|
14
|
+
export const OutlineCollectionsTool = {
|
|
15
|
+
name: "outline_collections",
|
|
16
|
+
label: "Outline Collections",
|
|
17
|
+
description:
|
|
18
|
+
"List the collections (top-level sections) of the Outline workspace with their ids. Use this to find the collectionId needed by outline_create, outline_search filters and outline_structure.",
|
|
19
|
+
parameters: Type.Object({
|
|
20
|
+
limit: Type.Optional(
|
|
21
|
+
Type.Number({ description: "How many collections to return. Default 50.", default: 50 }),
|
|
22
|
+
),
|
|
23
|
+
offset: Type.Optional(
|
|
24
|
+
Type.Number({ description: "Pagination offset. Default 0.", default: 0 }),
|
|
25
|
+
),
|
|
26
|
+
profile: Type.Optional(
|
|
27
|
+
Type.String({ description: "Outline profile to use. Defaults to the active one." }),
|
|
28
|
+
),
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
async execute(
|
|
32
|
+
_toolCallId: string,
|
|
33
|
+
params: { limit?: number; offset?: number; profile?: string },
|
|
34
|
+
signal: AbortSignal,
|
|
35
|
+
) {
|
|
36
|
+
const config = resolveConfig(params.profile);
|
|
37
|
+
const { collections, pagination } = await listCollections(
|
|
38
|
+
config,
|
|
39
|
+
{ limit: params.limit, offset: params.offset },
|
|
40
|
+
signal,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text" as const, text: formatCollectionList(collections) }],
|
|
45
|
+
details: {
|
|
46
|
+
count: collections.length,
|
|
47
|
+
collections: collections.map((c) => ({ id: c.id, name: c.name })),
|
|
48
|
+
pagination,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outline_comment tool -- Post a comment or a reply on a document.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { resolveConfig } from "../config.ts";
|
|
7
|
+
import { createComment } from "../clients/outline-client.ts";
|
|
8
|
+
|
|
9
|
+
export const OutlineCommentTool = {
|
|
10
|
+
name: "outline_comment",
|
|
11
|
+
label: "Outline Post Comment",
|
|
12
|
+
description:
|
|
13
|
+
"Post a comment on a document, or reply to an existing comment thread. Use this to leave a question or a note for the humans who own the page instead of editing it silently.",
|
|
14
|
+
parameters: Type.Object({
|
|
15
|
+
documentId: Type.String({ description: "Document id to comment on." }),
|
|
16
|
+
text: Type.String({ description: "Comment text." }),
|
|
17
|
+
parentCommentId: Type.Optional(
|
|
18
|
+
Type.String({ description: "Reply inside this comment thread." }),
|
|
19
|
+
),
|
|
20
|
+
profile: Type.Optional(
|
|
21
|
+
Type.String({ description: "Outline profile to use. Defaults to the active one." }),
|
|
22
|
+
),
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
async execute(
|
|
26
|
+
_toolCallId: string,
|
|
27
|
+
params: {
|
|
28
|
+
documentId: string;
|
|
29
|
+
text: string;
|
|
30
|
+
parentCommentId?: string;
|
|
31
|
+
profile?: string;
|
|
32
|
+
},
|
|
33
|
+
signal: AbortSignal,
|
|
34
|
+
) {
|
|
35
|
+
if (!params.text || !params.text.trim()) {
|
|
36
|
+
throw new Error("text must not be empty.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const config = resolveConfig(params.profile);
|
|
40
|
+
const comment = await createComment(
|
|
41
|
+
config,
|
|
42
|
+
{
|
|
43
|
+
documentId: params.documentId,
|
|
44
|
+
text: params.text.trim(),
|
|
45
|
+
parentCommentId: params.parentCommentId,
|
|
46
|
+
},
|
|
47
|
+
signal,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const kind = params.parentCommentId ? "Reply" : "Comment";
|
|
51
|
+
return {
|
|
52
|
+
content: [
|
|
53
|
+
{
|
|
54
|
+
type: "text" as const,
|
|
55
|
+
text: `${kind} posted on document ${params.documentId}.\nComment id: ${comment.id}`,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
details: {
|
|
59
|
+
id: comment.id,
|
|
60
|
+
documentId: params.documentId,
|
|
61
|
+
parentCommentId: params.parentCommentId ?? null,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|